eCourt-note/app/api/annotations/export/route.ts
Your Name 651e354cad stamp-editor: ตรายางพิมพ์เอง+จัดบรรทัดแบบถอยหลัง+มุมเอียง, editor กล่องข้อความ contentEditable + แก้ตาม code review 2026-09-29 ครบ 5 ข้อ — (1) STAMP คำพิมพ์เอง (D7): dropdown ของ tool stamp เป็น 2 หน้า — preset + แถว "กำหนดเอง…" (stampCustomMode reset ทุกครั้งที่เปิดเมนู) เปิด popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายาง live ตามสัดส่วนจริง (ความกว้างหน้าแรก × scale%) ใช้สูตร/ฟอนต์/measure ตัวเดียวกับ render จริง, คำพิมพ์ live ไป stampPreset เดียวกับ preset, Enter/ปุ่ม "ใช้ตรายางนี้" = ปิดเมนูแบบเลือกค่าจบ, ปุ่ม tool แสดง "กำหนดเอง" แทนคำเต็ม + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (StampEditor textarea ใน TextboxStampLayer ใช้ editingId/commitEdit ร่วมกับ TEXTBOX, จำกัด STAMP_MAX_LABEL_CHARS=50 ที่ UI — validator ยังยอม MAX_TEXT_CHARS ตามเจตนา) (2) จัดบรรทัดแบบถอยหลัง (คำขอผู้ใช้รอบสอง): สูตรย้ายรวมที่ utils/geometry.ts — stampLayout: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (stampFitFontSize เชิงเส้น) จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% = บรรทัดเดียว), ยาวเกิน floor กลับไปฟอนต์ใหญ่แล้ว wrapStampLabel ≤3 บรรทัด (×0.92/รอบ เกินบรรทัดยอมแลกกับฟอนต์อ่านออก), stampBoxHeight ให้ 1 บรรทัด = 0.4w เป๊ะเหมือนเดิม — ทุกมิติเป็นสัดส่วนของ w จำนวนบรรทัดจึงไม่ขึ้นกับ scale — constants + ฟังก์ชัน re-export จาก utils/annotation-display.ts ให้ import เดิมทุกจุด + measureStampText (canvas bold system-ui คืน 0 ตอน SSR) เป็น measure ของ render/preview/hit-test/eraser ทั้งฝั่ง client (annotationBBox รับ optional measure — ให้ความสูงกรอบตรง render จริง แก้ขอบล่างกรอบเกินตรายาง) ส่วนฝั่ง server (validator/export sticky note/describeAnnotationChange) ใช้ stampLayoutEstimate ความกว้างต่ออักษร 0.58 (3) มุมเอียง StampData.rotation? (−180..180 หมุนรอบกลางกรอบ): ช่อง "มุมเอียง" ในแผง selection (patch stampRotation, 0° = ถอด field) + จุดจับหมุนวงกลมเหนือขอบบน (mode rotate — atan2 delta รอบกลางกรอบ + snap ทวีคูณ 15° ≤4°), กรอบ selection+handles หมุนด้วย CSS transform ที่ origin เป็นพิกัดใน element ผ่าน rotOn() (ใส่พิกัด page แล้ว div แต่ละตัวหมุนคนละจุดจนกรอบไม่ตรงตัวตรายาง — bug 2026-09-28), annotationBBox คืน AABB ของกรอบหมุน (stampRect/rotatedRectAABB/rotatePoint — anchor handle scale เป็นมุมหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), clampStampIntoPage เลื่อน position กลับเข้าหน้าตอนตั้งมุม (validator ตรวจ AABB ไม่ติดลบ ไม่ใช่ position ตรง ๆ — position มุมก่อนหมุนติดลบได้เมื่อกรอบจริงยังในหน้า), StampEditor แสดงตรง 0° พิมพ์ง่าย ปิดแล้วกลับไปเอียงค่าเดิม, export ใช้ pdf-lib rotate: degrees(−θ) + เลื่อน anchor ไปมุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center) + per-line baseline หมุนรอบกลางกรอบตรง CSS rotate ทุกองศา (4) TextboxEditor เป็น contentEditable WYSIWYG แทน textarea (ตัวหนาเฉพาะส่วน Ctrl/Cmd+B + Enter = insertLineBreak ไม่แตก div, paste เป็น plain text): utils/textbox-editor.ts ใหม่ — segmentsToHtml (escape & < > ก่อนใส่ dangerouslySetInnerHTML + <b> + <br>) และ serializeEditor (DOM → text รวม + segments แยกตัวหนา: ขึ้นบรรทัดจากทั้ง <br> และ <div>/<p> ที่ browser สร้าง — push \n ก่อนเข้า block ไม่ใช่หลังจบ block ไม่งั้น "abc<div>def</div>" ของ Chrome กลายเป็น "abcdef", dedupe <br>+block ติดกัน, trimEnd ตัดจาก segments ให้ตรง text กัน <br> ปิดท้ายของ browser ทิ้ง \n ค้างใน DB) — แยกเป็น pure DOM util ให้ unit test กับ jsdom ได้ (tests/utils/textbox-editor.test.ts ใหม่ 16 เคส ตาม pattern useCustomSelection-dom) (5) คิว update ต่อ id ใน PDFViewer (updateChainRef + pendingDataBaseRef): update ทุกตัวของ id เดียวกันเข้าคิวเดียวกัน base คำนวณตอนถึงตาคิวจาก data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ — จอสัมผัส blur-commit ข้อความแล้วแตะสี/ขนาดต่อทันที คอมมิต full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (code review 2026-09-28 รอบสอง) — ตัวควบคุมเล็กของแผง/editor commit เป็น AnnotationPatch ผสานผ่าน applyAnnotationPatch (color/strokeWidth/highlightStyle/highlightOpacity/stampScale/stampRotation/textbox/textboxText/label) (6) describeAnnotationChange: STAMP แสดง "มุมเอียง" และข้าม geometry เมื่อ label/มุมเปลี่ยน (geometry ต่างเป็นผลพลองของการจัดวาง แบบเดียวกับ TEXTBOX ที่ข้ามปรับขนาดเมื่อข้อความเปลี่ยน) (7) แก้ตาม code review 2026-09-29 ครบ: (a) patch stampScale/label ของตรายางเอียงเรียก clampStampIntoPage เหมือน stampRotation — เดิมขยาย/แก้ข้อความที่ชิดขอบแล้ว AABB ยื่นติดลบ update โดน validator ปฏิเสธเด้งกลับทั้งที่บนจอกรอบยังอยู่ในหน้า (b) validator STAMP ผ่อน tolerance ให้ AABB ติดลบได้ ≤2% ของหน้า กลืนความคลาด measure จริงของ browser กับ stampLayoutEstimate ฝั่ง server (ฟอนต์ต่อเครื่องทำให้จำนวนบรรทัด/AABB คลาด — ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธ) (c) applyUpdate: applyAnnotation เข้า try เดียวกับ action ให้ resolve() ใน finally ครอบทั้ง callback — เดิม dispatcher โยน sync แล้ว promise ไม่ settle ตลอดกาลจนคิวของ id นั้นตายถาวร + enqueueUpdate เปลี่ยน then(cleanup, cleanup) กัน unhandled rejection (d) catch ของ applyUpdate ล้าง pendingDataBaseRef — เดิม action กลางคิวล้มแล้ว update ถัดไปผสาน base ที่ไม่เคยติด DB จน commit การเปลี่ยนที่ล้มไปด้วย และ undo กู้สถานะที่ไม่มีอยู่จริง (e) ถอด as StampData cast 2 จุดใน AnnotationSelectionLayer — ย้าย stampData ที่ narrow ด้วย "label" in ขึ้นใช้แทนให้ type guard ทำงาน (f) export sticky note ของ STAMP ส่ง measure เดียวกับตอนวาดกรอบ (thaiFont.widthOfTextAtSize) ให้มุมเกาะตรงกรอบจริงไม่ใช่ estimate (8) InkLayer eraser ของ stamp ส่ง measureStampText ให้ bbox ตรง render จริง (9) docs sync ครบ: PLAN-FEAT.md D7/F18 + CLAUDE.md + rules/coordinates.md (patch เปลี่ยนขนาดกรอบเอียงต้อง clamp + tolerance validator 2%) + rules/pdf-viewer.md — ผ่าน code review (ไม่มี bug ระดับต้องแก้) + tsc + vitest 193/193 (คำขอผู้ใช้ 2026-09-28/29)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:56:00 +07:00

720 lines
34 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,
PDFHexString,
PDFName,
PDFString,
LineCapStyle,
degrees,
rgb,
type PDFFont,
type PDFDict,
type PDFPage,
type PDFRef,
} from "pdf-lib";
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
import {
annotationReplies,
annotations,
bookmarks,
files,
toAnnotationDTO,
type AnnotationType,
type BookmarkRow,
type CommentData,
type DrawData,
type HighlightData,
type RedactionData,
type ReplyRow,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
import {
displayRectToPdf,
displaySize,
displayToPdfPoint,
encodeRFC5987,
hexToRgb01,
wrapStyledText,
type Rot,
type StyledRun,
} from "@/utils/pdf-export";
import type { AnnotationDTO, Rect } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { fileKindFromUrl } from "@/utils/file-kind";
import { imageDocFromBytes } from "@/utils/image-to-pdf";
import {
annotationBBox,
rotatePoint,
stampBoxHeight,
stampLayout,
textboxMaxWidthPct,
} from "@/utils/geometry";
import {
STAMP_COLOR,
annotationNote,
effectiveDrawOpacity,
} from "@/utils/annotation-display";
// Export PDF ฝัง annotation + bookmarks (F8/C1 — PLAN-FEAT.md §3)
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → TEXTBOX/STAMP
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
// - COMMENT เป็น sticky note จริงของ PDF (/Subtype /Text — คลิกหมุดที่ reader เปิด
// popup อ่าน message + replies), bookmarks เป็น PDF outline (แผง Bookmarks)
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 → TEXTBOX → STAMP
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า — D4) — COMMENT ไม่เข้าลำดับนี้แล้ว:
// เป็น annotation object ที่ reader วาดเหนือ content เสมอ จึงไม่มีปัญหาถูกกลบ
const TYPE_ORDER: Record<AnnotationType, number> = {
HIGHLIGHT: 0,
DRAW: 1,
SHAPE: 2,
REDACTION: 3,
COMMENT: 4,
TEXTBOX: 5,
STAMP: 6,
};
/** ISO string → PDF date string (D:YYYYMMDDHHmmSSZ) สำหรับ /M ของ annotation */
function toPdfDate(iso: string): string {
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, "0");
return `D:${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
}
/**
* โน้ตคำอธิบาย (N1) ของ annotation → sticky note เล็กแนบมุมขวาบนของ bbox —
* Chrome/Edge แสดง tooltip จาก /Contents ตอน hover, Acrobat คลิกเปิด popup
* (แทน hover tooltip บนเว็บที่ flatten ลงหน้าแล้วอ่านไม่ได้)
*/
function attachNoteSticky(
doc: PDFDocument,
page: PDFPage,
a: AnnotationDTO,
note: string,
anchor: Point, // มุมขวาบนของ bbox ในพิกัด PDF (pt)
): void {
const annot = doc.context.obj({
Type: "Annot",
Subtype: "Text",
// หมุดเล็ก 16pt ห้อยลงจากจุดยึด (แกน y ของ PDF ชี้ขึ้น)
Rect: [anchor.x, anchor.y - 16, anchor.x + 16, anchor.y],
Contents: PDFHexString.fromText(note),
T: PDFHexString.fromText(a.userName),
C: [254 / 255, 243 / 255, 199 / 255],
M: PDFString.of(toPdfDate(a.updatedAt)),
F: 4, // Print — พิมพ์แล้ว icon ติดไปด้วย
});
page.node.addAnnot(doc.context.register(annot));
}
/**
* ฝัง bookmarks เป็น PDF outline (แผง Bookmarks ของ reader กดกระโดดได้) — โครงสร้าง
* ตาม parentId (DB จำกัด 3 ระดับอยู่แล้ว) เรียงตามลำดับหน้าเหมือน sidebar (N5 —
* ลำดับมาจาก query ด้านผู้เรียก คง relative order ไว้ทั้ง root และ children ในแม่เดียวกัน) แต่ละรายการ
* /Dest ชี้หน้า + ตำแหน่ง yOffset — pdf-lib ไม่มี API ระดับสูงสำหรับ outline จึงต่อ
* PDFDict เอง (Title เป็น PDFHexString UTF-16BE รองรับภาษาไทย)
*/
function buildOutline(doc: PDFDocument, rows: BookmarkRow[]): void {
if (rows.length === 0) return;
const ctx = doc.context;
const ids = new Set(rows.map((b) => b.id));
const childrenOf = new Map<string, BookmarkRow[]>();
const roots: BookmarkRow[] = [];
for (const b of rows) {
// parent หาย (ความครบถ้วนของข้อมูล) → ผลักขึ้น root เหมือนที่ sidebar ทำ
if (b.parentId && ids.has(b.parentId)) {
const list = childrenOf.get(b.parentId) ?? [];
list.push(b);
childrenOf.set(b.parentId, list);
} else {
roots.push(b);
}
}
// Major 2 (code review): ข้อมูลวนซ้ำไร้ root (A แม่ของ B, B แม่ของ A — action/sidebar กัน
// ตอนเขียนแล้ว แต่กันเคส DB ถูกแก้ตรง ๆ) roots จะว่างและ kids[0] พังทั้ง export (500) —
// ปล่อยเป็นไฟล์ไม่มี outline เงียบ ๆ ดีกว่าไฟล์พังทั้งใบ
if (roots.length === 0) return;
const buildNode = (
b: BookmarkRow,
parentRef: PDFRef,
): { ref: PDFRef; dict: PDFDict; count: number } => {
const ref = ctx.nextRef();
const kids = (childrenOf.get(b.id) ?? []).map((k) => buildNode(k, ref));
const page = doc.getPage(
Math.min(Math.max(b.pageIndex, 0), doc.getPageCount() - 1),
);
const size = page.getSize();
const rotation = (((page.getRotation().angle % 360) + 360) % 360) as Rot;
const display = displaySize(size, rotation);
// yOffset (% ของความสูงหน้าบนจอ) → พิกัด PDF ทางเดียวกับ annotation (invariant #2)
// นิยาม yOffset = "จุดจริงบนหน้า" (ขอบบนของสิ่งที่ดูอยู่ตอนสร้าง) และในเว็บกดแล้ว
// จุดถูกดันชิดใต้ header — dest /XYZ ของ PDF ก็วางจุดยึดติดขอบบน viewport เหมือนกัน
// จึงใช้ตำแหน่งเป๊ะ ๆ ไม่ต้องชดเชย (ก่อนหน้านี้ yOffset เป็น "กึ่งกลางจอตอนสร้าง"
// จึงเคยต้องเลื่อนไถออก 25% ของความสูงหน้า)
const dest = displayToPdfPoint(
{ x: 0, y: toPx(b.yOffset, display.height) },
size,
rotation,
);
const count = kids.reduce((sum, k) => sum + 1 + k.count, 0);
const dict = ctx.obj({
Title: PDFHexString.fromText(b.title),
Parent: parentRef,
// /XYZ = กระโดดไปตำแหน่งนี้โดยคง zoom ของผู้อ่าน (null)
Dest: [page.ref, PDFName.of("XYZ"), dest.x, dest.y, null],
...(kids.length > 0
? { First: kids[0].ref, Last: kids[kids.length - 1].ref, Count: count }
: {}),
});
for (let i = 0; i < kids.length; i++) {
if (i > 0) kids[i].dict.set(PDFName.of("Prev"), kids[i - 1].ref);
if (i < kids.length - 1)
kids[i].dict.set(PDFName.of("Next"), kids[i + 1].ref);
}
ctx.assign(ref, dict);
return { ref, dict, count };
};
const rootRef = ctx.nextRef();
const kids = roots.map((r) => buildNode(r, rootRef));
for (let i = 0; i < kids.length; i++) {
if (i > 0) kids[i].dict.set(PDFName.of("Prev"), kids[i - 1].ref);
if (i < kids.length - 1) kids[i].dict.set(PDFName.of("Next"), kids[i + 1].ref);
}
ctx.assign(
rootRef,
ctx.obj({
Type: "Outlines",
First: kids[0].ref,
Last: kids[kids.length - 1].ref,
// Count เป็นบวก = แผง outline เปิดทั้งหมดตอนเปิดไฟล์
Count: kids.reduce((sum, k) => sum + 1 + k.count, 0),
}),
);
doc.catalog.set(PDFName.of("Outlines"), rootRef);
}
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)
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
let srcBytes: Buffer;
if (isRemoteSource(file.pdfUrl)) {
try {
srcBytes = await fetchSourceBytes(file.pdfUrl);
} catch (err) {
if (err instanceof RemoteFileError) return new Response(err.message, { status: 502 });
throw err;
}
} else {
try {
srcBytes = readPublicPdf(file.pdfUrl);
} catch (err) {
if (err instanceof PdfPathError) {
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
}
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
}
}
// N3: ไฟล์รูปภาพ — สร้าง PDF ใหม่ที่ฝังรูปเต็มหน้า (embedJpg/embedPng + EXIF rotate)
// แล้ววาด annotation ต่อด้วย pipeline เดิมทั้งหมด (rotation=0, page 595×สัดส่วนรูป
// ตรง scale contract ฝั่ง viewer → พิกัด % ตรงกันทั้งจอและ export)
// ไฟล์เสีย (ผ่าน magic bytes ตอนอัปโหลดแต่เนื้อข้างในพัง) → ข้อความชัด ๆ แทน 500 เปล่า
let doc: Awaited<ReturnType<typeof PDFDocument.load>>;
try {
doc =
fileKindFromUrl(file.pdfUrl) === "image"
? await imageDocFromBytes(srcBytes, file.pdfUrl)
: await PDFDocument.load(srcBytes);
} catch (err) {
console.error("สร้างเอกสารสำหรับ export ไม่สำเร็จ", err);
return new Response("ไฟล์ต้นฉบับเสียหาย สร้างเอกสาร export ไม่ได้", { status: 500 });
}
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));
// F4: export ตาม filter รายคนที่กำลังดู (?user=<userId> จาก sidebar — ไม่ส่ง = ทั้งหมด)
// REDACTION ติดไปเสมอแม้กรองคนอื่น (D4 — กล่องดำหายไป = เนื้อหาที่ปิดกั้นกลับมาอ่านได้)
const userParam = url.searchParams.get("user");
const filteredRows = userParam
? rows.filter((r) => r.userId === userParam || r.type === "REDACTION")
: rows;
// ชื่อผู้ใช้เติมท้ายชื่อไฟล์ (mirror ในตาราง — ไม่รับ text จาก client) — กรองแล้ว
// ผู้นั้นยังไม่มีรายการเลย = ใช้ userId ตรง ๆ
const filterName = userParam
? (rows.find((r) => r.userId === userParam && r.userName)?.userName ??
userParam)
: null;
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
const dtos = filteredRows
.map(toAnnotationDTO)
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
// replies ของ COMMENT สำหรับฝังลง popup ของ sticky note (F9) — query เฉพาะเมื่อมี COMMENT
const repliesByAnnotation = new Map<string, ReplyRow[]>();
const commentIds = dtos.filter((a) => a.type === "COMMENT").map((a) => a.id);
if (commentIds.length > 0) {
const replyRows = await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, commentIds))
.orderBy(asc(annotationReplies.createdAt));
for (const r of replyRows) {
const list = repliesByAnnotation.get(r.annotationId) ?? [];
list.push(r);
repliesByAnnotation.set(r.annotationId, list);
}
}
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": {
// sticky note จริงของ PDF (/Subtype /Text) — คลิกหมุดที่ reader แล้วเปิด popup
// อ่าน message + replies (F9); ไม่ต้องมี /AP เพราะ reader วาด icon เอง
const d = a.data as CommentData;
const pin = toPdf(pxPoint(d.position));
const parts = [d.message];
for (const r of repliesByAnnotation.get(a.id) ?? []) {
parts.push(`— ${r.userName}: ${r.message}`);
}
const annot = doc.context.obj({
Type: "Annot",
Subtype: "Text",
// หมุด icon 20pt ห้อยลงจากจุด position (แกน y ของ PDF ชี้ขึ้น)
Rect: [pin.x, pin.y - 20, pin.x + 20, pin.y],
Contents: PDFHexString.fromText(parts.join("\n")),
T: PDFHexString.fromText(a.userName),
// สีเดียวกับหมุดบนจอ (เหลืองอ่อน) — reader เอาไปทา icon
C: [254 / 255, 243 / 255, 199 / 255],
M: PDFString.of(toPdfDate(a.updatedAt)),
F: 4, // Print — พิมพ์แล้ว icon ติดไปด้วย
});
page.node.addAnnot(doc.context.register(annot));
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);
// clamp ขอบขวาแบบเดียวกับบนจอ (textboxMaxWidthPct) — ไม่งั้นกล่อง
// ที่ position.x สูง (ครึ่งขวาของหน้า) ข้อความทุกบรรทัดทะลุขอบหน้ากระดาษ
// กล่อง width คงที่ (ถ้ามี) wrap ที่ความกว้างนั้น (บีบด้วย cap เหมือน render จอ)
const capPct = textboxMaxWidthPct(d.position.x);
const maxWidth = Math.max(
px(Math.min(d.width ?? capPct, capPct), display.width),
10,
);
// 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);
// กล่องห้อยจากมุมซ้ายบน (pos) เหมือน render จอ — align จัดบรรทัด*ใน*กล่องอย่างเดียว
// (แบบ Smallpdf กล่องไม่ขยับ): กล่อง width คงที่กว้างเต็ม maxWidth ตามที่กำหนด
// ส่วนแถวเดิม shrink-to-fit กว้างเท่าบรรทัดยาวสุด (ไม่เกิน maxWidth)
const align = d.align ?? "left";
const lineWOf = (lineRuns: StyledRun[]) =>
lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
const boxW =
d.width !== undefined
? maxWidth
: Math.max(Math.min(...lines.map(lineWOf), maxWidth), 1);
const boxLeft = pos.x;
lines.forEach((lineRuns, i) => {
const lineW = lineWOf(lineRuns);
let x =
align === "center"
? boxLeft + (boxW - lineW) / 2
: align === "right"
? boxLeft + (boxW - lineW)
: boxLeft;
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);
// จัดวางข้อความแบบเดียวกับบนจอ (stampLayout): คำสั้น 1 บรรทัด, คำยาวพอดี ๆ
// ย่อฟอนต์ให้บรรทัดเดียวก่อน (จนถึง floor 10%), ยาวเกินค่อย wrap หลายบรรทัด
// กรอบสูงขึ้นตาม — measure ของฝั่งนี้คือ pdf-lib font (2026-09-28)
const lay = thaiFont
? stampLayout(d.label, w, (s, size) => thaiFont.widthOfTextAtSize(s, size))
: null;
const rect = displayRectToPdf(
{
x: px(d.position.x, display.width),
y: px(d.position.y, display.height),
width: w,
height: lay ? stampBoxHeight(lay, w) : w * 0.4,
},
size,
rotation,
);
// มุมเอียงของตรายาง: จอหมุนรอบ**กลางกรอบ** ส่วน pdf-lib หมุนรอบ anchor มุม
// ล่างซ้ายของ object — จึงต้อง (1) ใช้มุมลบ (แกน y ของ PDF ชี้ขึ้น ส่วน CSS
// rotate วัดบนจอที่ y ชี้ลง) และ (2) เลื่อน anchor ไปตำแหน่งมุมล่างซ้าย*หลัง
// หมุน* ให้ pdf-lib หมุนต่อรอบมัน ผลลัพธ์กลับมาตรงตำแหน่งเดียวกับบนจอ
const rot = d.rotation ?? 0;
const pdfRot = -rot;
const ctr = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
const anchor = rot
? rotatePoint({ x: rect.x, y: rect.y }, ctr, pdfRot)
: { x: rect.x, y: rect.y };
const rotateOpt = rot ? { rotate: degrees(pdfRot) } : undefined;
page.drawRectangle({
x: anchor.x,
y: anchor.y,
width: rect.width,
height: rect.height,
borderColor: stampRgb,
borderWidth: Math.max(w * 0.02, 1),
...rotateOpt,
});
if (lay && thaiFont) {
const font = thaiFont;
// วาดทีละบรรทัดจากกึ่งกลางแนวตั้งของกรอบ (pdf y ชี้ขึ้นจากล่าง) — จุดวาด
// คำนวณในพิกัดกรอบ*ก่อนหมุน* แล้วหมุนรอบกลางกรอบเหมือนจอก่อนส่ง drawText
const lineStep = lay.fontSize * lay.lineHeight;
const totalH = lay.lines.length * lineStep;
let baseline = rect.y + rect.height / 2 + totalH / 2 - lay.fontSize;
for (const ln of lay.lines) {
const textW = font.widthOfTextAtSize(ln, lay.fontSize);
const tx = rect.x + (rect.width - textW) / 2;
const p = rot
? rotatePoint({ x: tx, y: baseline }, ctr, pdfRot)
: { x: tx, y: baseline };
page.drawText(ln, {
x: p.x,
y: p.y,
size: lay.fontSize,
font,
color: stampRgb,
...rotateOpt,
});
baseline -= lineStep;
}
}
break;
}
}
// โน้ตคำอธิบาย (N1) → sticky note แนบมุมขวาบนของ bbox — COMMENT ไม่มี note
// (มี message อยู่ใน popup ของตัวเองแล้ว)
if (a.type !== "COMMENT") {
const note = annotationNote(a);
if (note) {
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF — ส่ง
// measure ของ pdf-lib font เดียวกับตอนวาดกรอบ STAMP ด้วย ให้มุมเกาะ sticky
// note ตรงกรอบที่วาดจริง (ไม่ส่ง = stampLayoutEstimate คลาดจากกรอบจริงเล็กน้อย
// — code review 2026-09-29)
const bbox = annotationBBox(
a,
display,
thaiFont ? (s, size) => thaiFont.widthOfTextAtSize(s, size) : undefined,
);
attachNoteSticky(
doc,
page,
a,
note,
toPdf({ x: bbox.x + bbox.width, y: bbox.y }),
);
}
}
}
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
// เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง)
// กรองตามรายคนเหมือน annotation (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่
// ลูกขึ้น root (buildOutline จัดการ parent หายเคสนี้อยู่แล้ว)
const bookmarkRows = await db
.select()
.from(bookmarks)
.where(
userParam
? and(eq(bookmarks.fileId, fileId), eq(bookmarks.userId, userParam))
: eq(bookmarks.fileId, fileId),
)
.orderBy(
asc(bookmarks.pageIndex),
asc(bookmarks.yOffset),
asc(bookmarks.createdAt),
);
buildOutline(doc, bookmarkRows);
const out = await doc.save();
const filename = encodeRFC5987(
`${file.title}-annotated${filterName ? `-${filterName}` : ""}.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}`,
},
});
}