eCourt-note/app/api/annotations/export/route.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 6fd3a1515f ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว
- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ
  จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้
  ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark
  ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes
  เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง
- ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin
  เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ)
  ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests)
- mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่
- Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath
  หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้
  RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh
- คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ
  focus อยู่ใน input/textarea/select/contenteditable)
- STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ
  export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง
  selection (uniform scale + clamp รายแกน)
- sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00

449 lines
19 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 fs from "node:fs";
import path from "node:path";
import fontkit from "@pdf-lib/fontkit";
import { PDFDocument, LineCapStyle, rgb, type PDFFont } from "pdf-lib";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import {
annotations,
files,
toAnnotationDTO,
type AnnotationType,
type CommentData,
type DrawData,
type HighlightData,
type RedactionData,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
import {
displayRectToPdf,
displaySize,
displayToPdfPoint,
encodeRFC5987,
hexToRgb01,
wrapStyledText,
wrapTextByWidth,
type Rot,
type StyledRun,
} from "@/utils/pdf-export";
import type { Rect } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
import { STAMP_COLOR, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display";
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → COMMENT หมุด
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf");
// ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → COMMENT
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า และหมุด COMMENT ต้องไม่ถูกกลบ — D4)
const TYPE_ORDER: Record<AnnotationType, number> = {
HIGHLIGHT: 0,
DRAW: 1,
SHAPE: 2,
REDACTION: 3,
COMMENT: 4,
TEXTBOX: 5,
STAMP: 6,
};
export async function GET(req: Request) {
const url = new URL(req.url);
const fileId = url.searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
let srcBytes: Buffer;
try {
srcBytes = readPublicPdf(file.pdfUrl);
} catch (err) {
if (err instanceof PdfPathError) {
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
}
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
}
const doc = await PDFDocument.load(srcBytes);
doc.registerFontkit(fontkit);
// D5: ฟอนต์ไทย optional — ไม่มีก็ export สำเร็จ (COMMENT เป็นหมุดอย่างเดียว)
let thaiFont: PDFFont | null = null;
const fontPath = path.join(process.cwd(), "public", SARABUN_REL);
if (fs.existsSync(fontPath)) {
try {
thaiFont = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
} catch (err) {
console.error("embed Sarabun ไม่สำเร็จ — ไปทาง fallback หมุดอย่างเดียว", err);
thaiFont = null;
}
}
// TEXTBOX ตัวหนา — optional เช่นกัน: ไม่มี Sarabun-Bold.ttf ก็ fallback เป็น Regular
// (บนจอ font-weight 700 อยู่แล้ว แต่ export ต้องมีไฟล์น้ำหนักหนาจริงจึงตรงกัน)
let thaiBoldFont: PDFFont | null = null;
const boldFontPath = path.join(process.cwd(), "public", "fonts", "Sarabun-Bold.ttf");
if (fs.existsSync(boldFontPath)) {
try {
thaiBoldFont = await doc.embedFont(fs.readFileSync(boldFontPath), { subset: true });
} catch (err) {
console.error("embed Sarabun-Bold ไม่สำเร็จ — ตัวหนา fallback เป็น Regular", err);
thaiBoldFont = null;
}
}
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.createdAt));
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
const dtos = rows
.map(toAnnotationDTO)
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
for (const a of dtos) {
const page = doc.getPage(Math.min(a.pageIndex, doc.getPageCount() - 1));
const pageSize = page.getSize();
const size: Size = { width: pageSize.width, height: pageSize.height };
const rotation = (((page.getRotation().angle % 360) + 360) % 360) as Rot;
const display = displaySize(size, rotation);
// % ของหน้า (บนจอ) → px บนจอ — ผ่าน utils/coordinates เท่านั้น (invariant #2)
const px = toPx;
const pxPoint = (p: Point): Point => ({
x: px(p.x, display.width),
y: px(p.y, display.height),
});
const pxRect = (r: Rect): Rect => ({
x: px(r.x, display.width),
y: px(r.y, display.height),
width: px(r.width, display.width),
height: px(r.height, display.height),
});
const toPdf = (p: Point) => displayToPdfPoint(p, size, rotation);
// strokeWidth เป็น % ของความกว้างหน้า **บนจอ**
const thickness = (swPct: number) => px(swPct, display.width);
// AnnotationDTO เป็น flat type (type/data แยกกัน) → cast ตาม case ที่เช็คแล้ว (pattern เดียวกับ sidebar)
switch (a.type) {
case "HIGHLIGHT": {
const d = a.data as HighlightData;
const { r, g, b } = hexToRgb01(d.color);
const col = rgb(r, g, b);
const st = d.style ?? "highlight";
// ความทึบของผู้ใช้ (0–1, ไม่มี = ทึบเต็ม) — ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35
// (สอดคล้อง mix-blend-multiply บนจอ), เส้นทุกแบบใช้ตรง ๆ
const op = d.opacity ?? 1;
for (const rPct of d.rects) {
const rect = displayRectToPdf(pxRect(rPct), size, rotation);
if (st === "underline") {
// เส้นใต้ที่ขอบล่างของบรรทัด (rect.y ของ PDF = ขอบล่างบนจอ)
page.drawLine({
start: { x: rect.x, y: rect.y },
end: { x: rect.x + rect.width, y: rect.y },
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
} else if (st === "strikethrough") {
const y = rect.y + rect.height / 2;
page.drawLine({
start: { x: rect.x, y },
end: { x: rect.x + rect.width, y },
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
} else if (st === "squiggle") {
// เส้นขยุกขยิก — sine เล็ก ๆ ระดับขอบล่าง วาดต่อเป็นท่อนสั้น ๆ
let prev = { x: rect.x, y: rect.y + 1.5 };
for (let t = 2; t <= rect.width; t += 2) {
const p = {
x: rect.x + t,
y: rect.y + 1.5 * Math.sin((t / 6) * Math.PI * 2),
};
page.drawLine({
start: prev,
end: p,
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
prev = p;
}
} else {
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: col,
opacity: 0.35 * op,
});
}
}
break;
}
case "DRAW": {
const d = a.data as DrawData;
const { r, g, b } = hexToRgb01(d.strokeColor);
const t = Math.max(thickness(d.strokeWidth), 0.5);
// ความทึบตกลงมาจาก effectiveDrawOpacity (ค่าเดียวกับ render บนจอ) — โปร่งใส
// ใช้ lineCap butt กันปลาย segment ซ้อนกันเป็นจุดเข้มกว่าช่วงอื่น
const op = effectiveDrawOpacity(d);
const pts = d.paths.map((p) => toPdf(pxPoint(p)));
for (let i = 0; i < pts.length - 1; i++) {
page.drawLine({
start: pts[i],
end: pts[i + 1],
thickness: t,
color: rgb(r, g, b),
opacity: op,
lineCap: op < 1 ? LineCapStyle.Butt : LineCapStyle.Round,
});
}
break;
}
case "SHAPE": {
const d = a.data as ShapeData;
const { r, g, b } = hexToRgb01(d.strokeColor);
const color = rgb(r, g, b);
const t = Math.max(thickness(d.strokeWidth), 0.5);
if ("rect" in d) {
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
if (d.shape === "ellipse") {
page.drawEllipse({
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
xScale: Math.max(rect.width / 2, 0.1),
yScale: Math.max(rect.height / 2, 0.1),
borderColor: color,
borderWidth: t,
});
} else {
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: color,
borderWidth: t,
});
}
} else {
const p1 = toPdf(pxPoint(d.from));
const p2 = toPdf(pxPoint(d.to));
page.drawLine({
start: p1,
end: p2,
thickness: t,
color,
lineCap: LineCapStyle.Round,
});
if (d.shape === "arrow") {
// หัวลูกศร = เส้นสั้น 2 เส้นกลับหลังจากปลาย to (มุม ±π/7 เหมือนบนจอ)
const head = Math.max(t * 3.5, 8);
const ang = Math.atan2(p2.y - p1.y, p2.x - p1.x);
for (const spread of [Math.PI / 7, -Math.PI / 7]) {
page.drawLine({
start: p2,
end: {
x: p2.x - head * Math.cos(ang + spread),
y: p2.y - head * Math.sin(ang + spread),
},
thickness: t,
color,
lineCap: LineCapStyle.Round,
});
}
}
}
break;
}
case "REDACTION": {
// ดำทึบทับจริง — ความปลอดภัยของ redaction อยู่ที่นี่ที่เดียว (D4)
const d = a.data as RedactionData;
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: rgb(0, 0, 0),
});
break;
}
case "COMMENT": {
const d = a.data as CommentData;
const pin = toPdf(pxPoint(d.position));
page.drawCircle({
x: pin.x,
y: pin.y,
size: 12,
color: rgb(254 / 255, 243 / 255, 199 / 255),
borderColor: rgb(252 / 255, 211 / 255, 77 / 255),
borderWidth: 1,
});
if (thaiFont) {
const font = thaiFont;
const fontSize = 9;
// FIX-5: ย่อความกว้างกล่องตามหน้าจริง — หน้าแคบกว่า 220pt
// เดิมทำให้ x ติดลบจนข้อความหลุดออกนอกขอบซ้ายเงียบ ๆ
const maxWidth = Math.min(220, size.width);
const lines = wrapTextByWidth(
`${a.userName}: ${d.message}`,
maxWidth,
(s) => font.widthOfTextAtSize(s, fontSize),
);
// FIX-5 (ต่อ): กันหลุดขอบบน — ข้อความวาด "ขึ้น" จากหมุด (แกน y ของ PDF ชี้ขึ้น)
// เมื่อหมุดใกล้ขอบบนหรือ wrap ยาวหลายบรรทัดจนบรรทัดบนสุดเลยขอบหน้า
// เลื่อนทั้งก้อนลงมาเท่าที่จำเป็น (ไม่ clamp รายบรรทัด ไม่งั้นบรรทัดซ้อนกัน)
const lineH = fontSize + 2;
const blockTop = pin.y + 4 + (lines.length - 1) * lineH;
const shift = Math.max(0, blockTop + fontSize - size.height);
lines.forEach((line, i) => {
page.drawText(line, {
x: Math.max(0, Math.min(pin.x + 10, size.width - maxWidth)),
y: pin.y + 4 + i * lineH - shift,
size: fontSize,
font,
color: rgb(0.2, 0.2, 0.2),
});
});
}
break;
}
case "TEXTBOX": {
// F17/B2 — วาดข้อความด้วย Sarabun (D5); ไม่มีฟอนต์ → กรอบแทน ต้องไม่พัง
const d = a.data as TextboxData;
const pos = toPdf(pxPoint(d.position));
const { r, g, b } = hexToRgb01(d.color);
if (thaiFont) {
const fontSize = Math.max(thickness(d.fontSize), 4);
const maxWidth = px(TEXTBOX_MAX_WIDTH_PCT, display.width);
// run ต่อสไตล์: segments (ตัวหนาเฉพาะส่วน) หรือทั้งกล่องตาม d.bold
const runs: StyledRun[] = d.segments?.length
? d.segments.map((s) => ({ text: s.text, bold: !!(s.bold || d.bold) }))
: [{ text: d.text, bold: !!d.bold }];
const fontFor = (bold: boolean) => (bold && thaiBoldFont ? thaiBoldFont : thaiFont);
const widthOf = (s: string, bold: boolean) =>
fontFor(bold).widthOfTextAtSize(s, fontSize);
const lines = wrapStyledText(runs, maxWidth, widthOf);
// จัดแนวในกล่องกว้าง maxWidth — เลื่อน x ต่อบรรทัดตามความกว้างจริงของบรรทัดนั้น
const align = d.align ?? "left";
lines.forEach((lineRuns, i) => {
const lineW = lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
let x =
align === "center"
? pos.x + (maxWidth - lineW) / 2
: align === "right"
? pos.x + (maxWidth - lineW)
: pos.x;
for (const rn of lineRuns) {
page.drawText(rn.text, {
x,
// position เป็นมุมซ้ายบนบนจอ → เดินลงจากขอบบนทีละบรรทัด (พิกัด PDF แกน y ขึ้น)
y: pos.y - fontSize - i * (fontSize * 1.5),
size: fontSize,
font: fontFor(rn.bold),
color: rgb(r, g, b),
opacity: d.opacity ?? 1,
});
x += widthOf(rn.text, rn.bold);
}
});
} else {
const rect = displayRectToPdf(
pxRect({ x: d.position.x, y: d.position.y, width: 40, height: 10 }),
size,
rotation,
);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: rgb(r, g, b),
borderWidth: 1,
});
}
break;
}
case "STAMP": {
// F18/B5, D7 — วาดกรอบ+ข้อความ; ไม่มีฟอนต์ → กรอบอย่างเดียว (D5 ต้องไม่พัง)
const d = a.data as StampData;
const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR);
const stampRgb = rgb(r, g, b);
const w = px(d.scale, display.width);
const rect = displayRectToPdf(
{
x: px(d.position.x, display.width),
y: px(d.position.y, display.height),
width: w,
height: w * STAMP_HEIGHT_RATIO,
},
size,
rotation,
);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: stampRgb,
borderWidth: Math.max(w * 0.02, 1),
});
if (thaiFont) {
const font = thaiFont;
// ย่อฟอนต์ให้ label อยู่บรรทัดเดียวพอดีกรอบ — เหมือนที่ render บนจอ
// (ไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดแล้วล้นกรอบ)
const fontSize = stampFitFontSize(
d.label,
w,
(s, size) => font.widthOfTextAtSize(s, size),
);
const textW = font.widthOfTextAtSize(d.label, fontSize);
const lineH = fontSize * 1.2; // กึ่งกลางแนวตั้งตาม lineHeight บนจอ
page.drawText(d.label, {
x: rect.x + (rect.width - textW) / 2,
y: rect.y + (rect.height - lineH) / 2 + lineH - fontSize,
size: fontSize,
font,
color: stampRgb,
});
}
break;
}
}
}
const out = await doc.save();
const filename = encodeRFC5987(`${file.title}-annotated.pdf`);
return new Response(new Uint8Array(out), {
headers: {
"content-type": "application/pdf",
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย
"content-disposition": `attachment; filename="annotated.pdf"; filename*=UTF-8''${filename}`,
},
});
}