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>
This commit is contained in:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
146
utils/pdf-export.ts
Normal file
146
utils/pdf-export.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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(),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue