eCourt-note/utils/pdf-export.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 eddcec28c7 Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00

146 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(),
);
}