N7 รายงานรายการ bookmark เป็น PDF (requirement 5.3.15): route /api/bookmarks/export สร้างเอกสารใหม่ล้วนด้วย PDFDocument.create (ไม่โหลดไฟล์ต้นฉบับเลย — invariant #1) ต้นไม้ indent 3 ระดับเรียงตาม N5 + หัวรายงาน + แบ่งหน้าต่อบรรทัด + footer หน้า n จาก N + cycle guard (seen ร่วมทุก root เติมโหนดนอก root เป็นระดับ 1) + 0 bookmark = หน้าเดียวมีข้อความแจ้ง + ปุ่ม Download ท้ายแถว toggle เงื่อนไขค้นหาในแท็บ "ที่คั่นหน้า" (ลิงก์ธรรมดา pattern F16) + export F8: outline bookmark เรียงตามลำดับหน้าเหมือน sidebar (N5: pageIndex→yOffset→createdAt แทน createdAt เดี่ยว) + ถอด drawNoteBox กลับเป็น sticky note annotation อย่างเดียวตามสเปก §F8 ไม่วาดข้อความ flatten ทับหน้ากระดาษ (กล่องเหลืองแบนทับเนื้อหาเอกสาร — ผู้ใช้ตัดสินใจ; tooltip ตัดไทยใน Chrome เป็น bug ฝั่ง PDFium) + แผง selection ของ COMMENT/REDACTION ที่ไม่มี row สไตล์หุบเป็น w-fit ปุ่ม bookmark/ลบติดกันคั่นเส้นแนวตั้ง
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 49s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-17 10:09:57 +07:00
parent bc8ba83deb
commit 460cd9c600
7 changed files with 379 additions and 28 deletions

View file

@ -0,0 +1,224 @@
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<string, BookmarkRow[]>();
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<string>();
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}`,
},
});
}