import fs from "node:fs"; import path from "node:path"; import fontkit from "@pdf-lib/fontkit"; import { PDFDocument, rgb, type PDFFont, type PDFPage } from "pdf-lib"; import { asc, eq } from "drizzle-orm"; import { db } from "@/db"; import { bookmarks, files, type BookmarkRow } from "@/db/schema"; import { encodeRFC5987, wrapTextByWidth } from "@/utils/pdf-export"; // รายงานรายการ bookmark เป็น PDF (N7 — PLAN-BOOKMARK-PDF.md / PLAN-FEAT.md §7, requirement 5.3.15) // - สร้างเอกสาร PDF ใหม่ล้วน (PDFDocument.create) — ไม่โหลดไฟล์เอกสารต้นฉบับเลย // (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป จึงไม่เกี่ยว readPublicPdf) // - ต้นไม้ indent 3 ระดับ เรียงตามลำดับหน้า (N5) เหมือน sidebar — แต่ละรายการ: // ชื่อ + "หน้า N · ผู้บันทึก · วันที่บันทึก" + โน้ตใต้ชื่อ // - read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB) 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"); // ─── layout คงที่ของรายงาน (A4 portrait) ─── const PAGE_W = 595.28; const PAGE_H = 841.89; const MARGIN = 48; const INDENT_STEP = 14; // indent ต่อระดับของต้นไม้ const META_SIZE = 9; const LINE_H = 1.35; // พื้นที่เผื่อสระบน–ล่างของไทย (ไ ใ / ุ ึ) // slate-900 / slate-500 / slate-200 — โทนเดียวกับ sidebar const INK = rgb(15 / 255, 23 / 255, 42 / 255); const MUTED = rgb(100 / 255, 116 / 255, 139 / 255); const RULE = rgb(226 / 255, 232 / 255, 240 / 255); export async function GET(req: Request) { const fileId = new URL(req.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 }); const rows = await db .select() .from(bookmarks) .where(eq(bookmarks.fileId, fileId)) .orderBy( asc(bookmarks.pageIndex), asc(bookmarks.yOffset), asc(bookmarks.createdAt), ); // ฟอนต์จำเป็น — รายงานเป็นข้อความล้วน ไม่มี fallback ที่มีความหมาย // (ต่างจาก D5 ของ F8 ที่ annotation ยังวาดรูปได้จึง optional) const fontPath = path.join(process.cwd(), "public", SARABUN_REL); if (!fs.existsSync(fontPath)) { console.error("ไม่พบ public/fonts/Sarabun-Regular.ttf"); return new Response("ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf", { status: 500 }); } const doc = await PDFDocument.create(); doc.registerFontkit(fontkit); let font: PDFFont; try { font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true }); } catch (err) { console.error("embed Sarabun ไม่สำเร็จ (รายงาน bookmark)", err); return new Response("ฝังฟอนต์ไม่สำเร็จ สร้างรายงานไม่ได้", { status: 500 }); } const widthOf = (s: string, size: number) => font.widthOfTextAtSize(s, size); // ต้นไม้ตาม parentId — เดินตามลำดับ array ที่ query เรียงตาม N5 มาแล้ว // (ลูกในแต่ละแม่เรียงตาม หน้า → yOffset → เวลา อัตโนมัติ) // orphan (parentId ไม่อยู่ในชุด) → เลื่อนขึ้น root เหมือน sidebar/buildOutline const ids = new Set(rows.map((b) => b.id)); const childrenOf = new Map(); const roots: BookmarkRow[] = []; for (const b of rows) { 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); } } type Item = { row: BookmarkRow; level: number }; const items: Item[] = []; // visited กัน DFS วนไม่รู้จบเมื่อข้อมูลเป็นวงจร (defensive — action กันมาแล้ว) // ใช้ seen ชุดเดียวร่วมทุก root: แชร์ subtree ข้าม root ไม่เกิด (ลูกมีแม่เดียว) // เกิดจริงเฉพาะวงจร ซึ่งต้องกันไม่ให้เดินซ้ำ const seen = new Set(); const walk = (b: BookmarkRow, level: number): void => { if (seen.has(b.id)) return; seen.add(b.id); items.push({ row: b, level }); for (const k of childrenOf.get(b.id) ?? []) walk(k, level + 1); }; for (const r of roots) walk(r, 1); // cycle (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action) → โหนดในวงจรไม่มี root เข้าถึง // เติมเป็นรายการระดับ 1 ให้ครบทุกแถว "ข้อมูลไม่หาย" (ต่างจาก buildOutline ที่ drop // เงียบ ๆ — รายงานหายไปเฉย ๆ ยิ่งงงกว่า) — เคส roots ว่างทั้งไฟล์ (วงจรล้วน) ก็ // ออกมาแบนทั้งหมดระดับ 1 จากกลไกนี้เอง for (const row of rows) { if (!seen.has(row.id)) items.push({ row, level: 1 }); } // ─── วาด — y คือ "ขอบล่างของกล่องบรรทัดถัดไป" (baseline = y + ส่วนต่างของ line height) let page: PDFPage = doc.addPage([PAGE_W, PAGE_H]); let y = PAGE_H - MARGIN; // เช็คพื้นที่*ต่อบรรทัด* ไม่ใช่ต่อรายการ — บรรทัดเดียวพอกับหน้าเปล่าเสมอ จึงไม่มี // เคสแถวสูงเกินหน้าที่ต้องจัดการพิเศษ (โน้ตยาว ๆ ก็ไหลข้ามหน้าเอง) const advanceLine = (size: number): number => { const lh = size * LINE_H; if (y - lh < MARGIN) { page = doc.addPage([PAGE_W, PAGE_H]); y = PAGE_H - MARGIN; } y -= lh; return y + (lh - size); // baseline ชิดล่างของกล่องบรรทัด (เผื่อ descender ไทย) }; const drawTextLine = ( text: string, x: number, size: number, color = INK, ): void => { // ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง — ถ้าฝัง advanceLine() ใน argument // base ของ method call (`page`) ถูก resolve ก่อน arguments ตาม spec // ทำให้บรรทัดแรกของหน้าใหม่ (ที่ advanceLine เพิ่ง reassign page) ถูกวาด // กลับลงหน้าเก่าซ้อนบรรทัดบนสุด const ty = advanceLine(size); page.drawText(text, { x, y: ty, size, font, color }); }; // หัวรายงาน (หน้าแรกหน้าเดียว) — ชื่อไฟล์ wrap เหมือนรายการ (title ใน DB ยาวได้ถึง 200) drawTextLine("รายการที่คั่นหน้า", MARGIN, 16); for (const line of wrapTextByWidth(file.title, PAGE_W - MARGIN * 2, (s) => widthOf(s, 12), )) { drawTextLine(line, MARGIN, 12); } const created = new Date().toLocaleDateString("th-TH", { timeZone: "Asia/Bangkok", }); drawTextLine( `สร้างเมื่อ ${created} · ${rows.length} รายการ`, MARGIN, META_SIZE, MUTED, ); y -= 8; page.drawLine({ start: { x: MARGIN, y }, end: { x: PAGE_W - MARGIN, y }, thickness: 1, color: RULE, }); y -= 18; if (items.length === 0) { drawTextLine("ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้", MARGIN, 11, MUTED); } let first = true; for (const { row, level } of items) { // เว้นช่องว่างก่อนรายการ — หัวข้อระดับ 1 เว้นกว้างกว่าให้เห็นกลุ่ม // (y ตกหลุดขอบล่างจากการเว้น = advanceLine บรรทัดแรกเปิดหน้าใหม่เอง) y -= first ? 0 : level === 1 ? 12 : 8; first = false; const indent = (level - 1) * INDENT_STEP; const x = MARGIN + indent; const maxW = PAGE_W - MARGIN * 2 - indent; const titleSize = level === 1 ? 13 : 11; for (const line of wrapTextByWidth(row.title, maxW, (s) => widthOf(s, titleSize), )) { drawTextLine(line, x, titleSize); } const date = row.createdAt.toLocaleDateString("th-TH", { timeZone: "Asia/Bangkok", }); drawTextLine( `หน้า ${row.pageIndex + 1} · ${row.userName || "ไม่ทราบชื่อ"} · ${date}`, x, META_SIZE, MUTED, ); if (row.note) { for (const line of wrapTextByWidth(`โน้ต: ${row.note}`, maxW, (s) => widthOf(s, META_SIZE), )) { drawTextLine(line, x, META_SIZE, MUTED); } } } // footer เลขหน้า — วาดท้ายเมื่อรู้จำนวนหน้าทั้งหมด (เนื้อหาหยุดที่ ≥ MARGIN ไม่ชน 24pt) const pages = doc.getPages(); pages.forEach((p, i) => { const label = `หน้า ${i + 1} จาก ${pages.length}`; p.drawText(label, { x: (PAGE_W - widthOf(label, META_SIZE)) / 2, y: 24, size: META_SIZE, font, color: MUTED, }); }); const out = await doc.save(); const filename = encodeRFC5987(`${file.title}-bookmarks.pdf`); return new Response(new Uint8Array(out), { headers: { "content-type": "application/pdf", // filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16) "content-disposition": `attachment; filename="bookmarks.pdf"; filename*=UTF-8''${filename}`, }, }); }