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 = { 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(); 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>; 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= จาก 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(); 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}`, }, }); }