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>
224 lines
11 KiB
TypeScript
224 lines
11 KiB
TypeScript
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}`,
|
|
},
|
|
});
|
|
}
|