eCourt-note/utils/pdf-export.ts

147 lines
5.8 KiB
TypeScript
Raw Normal View History

import type { Rect } from "@/db/schema";
import type { Point, Size } from "@/utils/coordinates";
// ตัวช่วยแปลงพิกัดสำหรับ export PDF (F8/C1 — PLAN-FEAT.md §3, §5)
// พิกัดของเราเป็น % ของ "หน้าที่แสดงบนจอ" (pdfjs คิด /Rotate แล้ว, จุด 0,0 มุมบนซ้าย แกน y ลง)
// ฝั่ง pdf-lib วาดในพิกัด PDF ของหน้า **ที่ยังไม่ถูกหมุน** (จุด 0,0 มุมล่างซ้าย แกน y ขึ้น)
// → ทุกจุดต้องแปลงตามมุม /Rotate ก่อนวาดเสมอ
export type Rot = 0 | 90 | 180 | 270;
/** ขนาดหน้า "บนจอ" หลังคิด rotation — 90/270 สลับกว้าง-สูง */
export function displaySize(pageSize: Size, rotation: Rot): Size {
return rotation % 180 === 0
? pageSize
: { width: pageSize.height, height: pageSize.width };
}
/**
* จุดบนจอ (px, มุมบนซ้าย y ลง, ขอบเขต displaySize) → พิกัด PDF (pt, มุมล่างซ้าย y ขึ้น)
* /Rotate ของ PDF หมายถึง "หมุน content ตามเข็มนาฬิกาตอนแสดง" (ตามพฤติกรรม pdfjs)
* อนุพันธ์ inverse mapping ของการหมุน CW แต่ละมุม
*/
export function displayToPdfPoint(
p: Point,
pageSize: Size,
rotation: Rot,
): Point {
switch (rotation) {
case 90:
return { x: p.y, y: p.x };
case 180:
return { x: pageSize.width - p.x, y: p.y };
case 270:
return { x: pageSize.width - p.y, y: pageSize.height - p.x };
default:
return { x: p.x, y: pageSize.height - p.y };
}
}
/**
* rect บนจอ (แกนขนานกับจอ) → rect ในพิกัด PDF — หมุน 90/270 แล้วยังแกนขนาน
* (เพราะเป็นมุมฉากพอดี) แปลงสองมุมแล้ว normalize
*/
export function displayRectToPdf(
rect: Rect,
pageSize: Size,
rotation: Rot,
): Rect {
const a = displayToPdfPoint({ x: rect.x, y: rect.y }, pageSize, rotation);
const b = displayToPdfPoint(
{ x: rect.x + rect.width, y: rect.y + rect.height },
pageSize,
rotation,
);
return {
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
width: Math.abs(b.x - a.x),
height: Math.abs(b.y - a.y),
};
}
/** แปลงสี hex (#rrggbb) → สัดส่วน 0–1 สำหรับ rgb() ของ pdf-lib — ผิดรูปแบบใช้ดำ */
export function hexToRgb01(hex: string): { r: number; g: number; b: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
const v = m ? m[1] : "000000";
return {
r: parseInt(v.slice(0, 2), 16) / 255,
g: parseInt(v.slice(2, 4), 16) / 255,
b: parseInt(v.slice(4, 6), 16) / 255,
};
}
/** ตัดบรรทัดแบบตามความกว้างจริงของฟอนต์ (ภาษาไทยไม่มีช่องว่าง → เดินทีละตัวอักษร) */
export function wrapTextByWidth(
text: string,
maxWidth: number,
widthOf: (s: string) => number,
): string[] {
const lines: string[] = [];
for (const raw of text.split(/\r?\n/)) {
let cur = "";
for (const ch of raw) {
if (cur && widthOf(cur + ch) > maxWidth) {
lines.push(cur);
cur = ch;
} else {
cur += ch;
}
}
lines.push(cur);
}
return lines;
}
/** ช่วงข้อความที่มีสไตล์เดียวกัน (ตัวหนา/ปกติ) — ใช้ตอน export TEXTBOX แบบ rich text */
export type StyledRun = { text: string; bold: boolean };
/**
* ตัดบรรทัดข้อความที่มีสไตล์ผสม (ตัวหนาเฉพาะส่วน) ตามความกว้าง — แตกเป็นตัวอักษร
* (แต่ละตัวใช้ฟอนต์ตามสไตล์ของมันเองตอนวัดความกว้าง) แล้วม้วนกลับเป็น run ต่อบรรทัด
* พฤติกรรมการตัดคำเดียวกับ wrapTextByWidth (ตัดทีละตัวอักษร — ภาษาไทยไม่มีช่องว่างคำ)
*/
export function wrapStyledText(
runs: StyledRun[],
maxWidth: number,
widthOf: (s: string, bold: boolean) => number,
): StyledRun[][] {
const chars: { ch: string; bold: boolean }[] = [];
for (const r of runs) {
for (const ch of Array.from(r.text)) chars.push({ ch, bold: r.bold });
}
const lines: { ch: string; bold: boolean }[][] = [[]];
let w = 0;
for (const c of chars) {
if (c.ch === "\n") {
lines.push([]);
w = 0;
continue;
}
const cw = widthOf(c.ch, c.bold);
if (w > 0 && w + cw > maxWidth) {
lines.push([]);
w = 0;
}
lines[lines.length - 1].push(c);
w += cw;
}
return lines.map((line) => {
const out: StyledRun[] = [];
for (const c of line) {
const last = out[out.length - 1];
if (last && last.bold === c.bold) last.text += c.ch;
else out.push({ text: c.ch, bold: c.bold });
}
return out;
});
}
/** ตัดชื่อไฟล์แบบ RFC 5987 สำหรับ Content-Disposition (ชื่อไฟล์ภาษาไทย) */
export function encodeRFC5987(s: string): string {
return encodeURIComponent(s).replace(
/['()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}