From 460cd9c6000e8258bd56b96542432f67d7ef10f5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 10:09:57 +0700 Subject: [PATCH] =?UTF-8?q?N7=20=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=87?= =?UTF-8?q?=E0=B8=B2=E0=B8=99=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=20bookmark=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20P?= =?UTF-8?q?DF=20(requirement=205.3.15):=20route=20/api/bookmarks/export=20?= =?UTF-8?q?=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3=E0=B9=83=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=A5=E0=B9=89=E0=B8=A7=E0=B8=99=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2=20PDFDocument.create=20(=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B9=82=E0=B8=AB=E0=B8=A5=E0=B8=94=E0=B9=84=E0=B8=9F?= =?UTF-8?q?=E0=B8=A5=E0=B9=8C=E0=B8=95=E0=B9=89=E0=B8=99=E0=B8=89=E0=B8=9A?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B9=80=E0=B8=A5=E0=B8=A2=20=E2=80=94=20inv?= =?UTF-8?q?ariant=20#1)=20=E0=B8=95=E0=B9=89=E0=B8=99=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=89=20indent=203=20=E0=B8=A3=E0=B8=B0=E0=B8=94=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=E0=B9=80=E0=B8=A3=E0=B8=B5=E0=B8=A2=E0=B8=87=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=20N5=20+=20=E0=B8=AB=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=87=E0=B8=B2=E0=B8=99=20+=20?= =?UTF-8?q?=E0=B9=81=E0=B8=9A=E0=B9=88=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=95=E0=B9=88=E0=B8=AD=E0=B8=9A=E0=B8=A3=E0=B8=A3?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B8=94=20+=20footer=20=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=20n=20=E0=B8=88=E0=B8=B2=E0=B8=81=20N=20+=20?= =?UTF-8?q?cycle=20guard=20(seen=20=E0=B8=A3=E0=B9=88=E0=B8=A7=E0=B8=A1?= =?UTF-8?q?=E0=B8=97=E0=B8=B8=E0=B8=81=20root=20=E0=B9=80=E0=B8=95?= =?UTF-8?q?=E0=B8=B4=E0=B8=A1=E0=B9=82=E0=B8=AB=E0=B8=99=E0=B8=94=E0=B8=99?= =?UTF-8?q?=E0=B8=AD=E0=B8=81=20root=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=E0=B8=A3=E0=B8=B0=E0=B8=94=E0=B8=B1=E0=B8=9A=201)=20+?= =?UTF-8?q?=200=20bookmark=20=3D=20=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=A1=E0=B8=B5?= =?UTF-8?q?=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=E0=B9=81=E0=B8=88=E0=B9=89=E0=B8=87=20+=20=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=20Download=20=E0=B8=97=E0=B9=89=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=E0=B9=81=E0=B8=96=E0=B8=A7=20toggle=20=E0=B9=80?= =?UTF-8?q?=E0=B8=87=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=84=E0=B8=82?= =?UTF-8?q?=E0=B8=84=E0=B9=89=E0=B8=99=E0=B8=AB=E0=B8=B2=E0=B9=83=E0=B8=99?= =?UTF-8?q?=E0=B9=81=E0=B8=97=E0=B9=87=E0=B8=9A=20"=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=E0=B8=84=E0=B8=B1=E0=B9=88=E0=B8=99=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2"=20(=E0=B8=A5=E0=B8=B4=E0=B8=87=E0=B8=81?= =?UTF-8?q?=E0=B9=8C=E0=B8=98=E0=B8=A3=E0=B8=A3=E0=B8=A1=E0=B8=94=E0=B8=B2?= =?UTF-8?q?=20pattern=20F16)=20+=20export=20F8:=20outline=20bookmark=20?= =?UTF-8?q?=E0=B9=80=E0=B8=A3=E0=B8=B5=E0=B8=A2=E0=B8=87=E0=B8=95=E0=B8=B2?= =?UTF-8?q?=E0=B8=A1=E0=B8=A5=E0=B8=B3=E0=B8=94=E0=B8=B1=E0=B8=9A=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B9=80=E0=B8=AB=E0=B8=A1=E0=B8=B7?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20sidebar=20(N5:=20pageIndex=E2=86=92yOffset?= =?UTF-8?q?=E2=86=92createdAt=20=E0=B9=81=E0=B8=97=E0=B8=99=20createdAt=20?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=A7)=20+=20?= =?UTF-8?q?=E0=B8=96=E0=B8=AD=E0=B8=94=20drawNoteBox=20=E0=B8=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20sticky?= =?UTF-8?q?=20note=20annotation=20=E0=B8=AD=E0=B8=A2=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B8=AA=E0=B9=80=E0=B8=9B=E0=B8=81=20=C2=A7?= =?UTF-8?q?F8=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A7=E0=B8=B2=E0=B8=94?= =?UTF-8?q?=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=20flatten=20=E0=B8=97=E0=B8=B1=E0=B8=9A=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=81=E0=B8=A3=E0=B8=B0=E0=B8=94=E0=B8=B2?= =?UTF-8?q?=E0=B8=A9=20(=E0=B8=81=E0=B8=A5=E0=B9=88=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B9=80=E0=B8=AB=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=87=E0=B9=81?= =?UTF-8?q?=E0=B8=9A=E0=B8=99=E0=B8=97=E0=B8=B1=E0=B8=9A=E0=B9=80=E0=B8=99?= =?UTF-8?q?=E0=B8=B7=E0=B9=89=E0=B8=AD=E0=B8=AB=E0=B8=B2=E0=B9=80=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3=20=E2=80=94=20=E0=B8=9C?= =?UTF-8?q?=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=E0=B8=AA=E0=B8=B4=E0=B8=99=E0=B9=83=E0=B8=88;=20toolt?= =?UTF-8?q?ip=20=E0=B8=95=E0=B8=B1=E0=B8=94=E0=B9=84=E0=B8=97=E0=B8=A2?= =?UTF-8?q?=E0=B9=83=E0=B8=99=20Chrome=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20bug=20=E0=B8=9D=E0=B8=B1=E0=B9=88=E0=B8=87=20PDFium?= =?UTF-8?q?)=20+=20=E0=B9=81=E0=B8=9C=E0=B8=87=20selection=20=E0=B8=82?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20COMMENT/REDACTION=20=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A1=E0=B8=B5=20row=20?= =?UTF-8?q?=E0=B8=AA=E0=B9=84=E0=B8=95=E0=B8=A5=E0=B9=8C=E0=B8=AB=E0=B8=B8?= =?UTF-8?q?=E0=B8=9A=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20w-fit=20?= =?UTF-8?q?=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20bookmark/=E0=B8=A5?= =?UTF-8?q?=E0=B8=9A=E0=B8=95=E0=B8=B4=E0=B8=94=E0=B8=81=E0=B8=B1=E0=B8=99?= =?UTF-8?q?=E0=B8=84=E0=B8=B1=E0=B9=88=E0=B8=99=E0=B9=80=E0=B8=AA=E0=B9=89?= =?UTF-8?q?=E0=B8=99=E0=B9=81=E0=B8=99=E0=B8=A7=E0=B8=95=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- CLAUDE.md | 5 +- app/api/annotations/export/route.ts | 10 +- app/api/bookmarks/export/route.ts | 224 ++++++++++++++++++++ components/pdf/AnnotationSelectionLayer.tsx | 17 +- components/sidebar/AnnotationSidebar.tsx | 51 +++-- docs/PLAN-BOOKMARK-PDF.md | 87 ++++++++ docs/PLAN-FEAT.md | 13 +- 7 files changed, 379 insertions(+), 28 deletions(-) create mode 100644 app/api/bookmarks/export/route.ts create mode 100644 docs/PLAN-BOOKMARK-PDF.md diff --git a/CLAUDE.md b/CLAUDE.md index b970931..f2eeafd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ **Judge's Note** — ระบบ PDF Annotation สำหรับ e-Court (โปรเจกต์ Next.js แยกต่างหาก ไม่เกี่ยวกับ dms-main ที่เป็น Nuxt) - ฟีเจอร์: highlight ข้อความ, วาดเส้น freehand, sticky note comment, bookmark -- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N6) อ่านเพิ่ม: `docs/PLAN-FEAT.md` +- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N7) อ่านเพิ่ม: `docs/PLAN-FEAT.md` ## Tech Stack @@ -51,6 +51,7 @@ app/actions/files.ts ← Server Action uploadFile — validate + เขีย app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) +app/api/bookmarks/export/route.ts ← รายงานรายการ bookmark เป็น PDF (N7 — สร้าง PDF ใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ) app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/page.tsx ← รายการไฟล์ (server component) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer @@ -65,7 +66,7 @@ components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md`) - ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) diff --git a/app/api/annotations/export/route.ts b/app/api/annotations/export/route.ts index d635a3b..0b96dd9 100644 --- a/app/api/annotations/export/route.ts +++ b/app/api/annotations/export/route.ts @@ -122,7 +122,8 @@ function attachNoteSticky( /** * ฝัง bookmarks เป็น PDF outline (แผง Bookmarks ของ reader กดกระโดดได้) — โครงสร้าง - * ตาม parentId (DB จำกัด 3 ระดับอยู่แล้ว) เรียงตาม createdAt เหมือน sidebar แต่ละรายการ + * ตาม parentId (DB จำกัด 3 ระดับอยู่แล้ว) เรียงตามลำดับหน้าเหมือน sidebar (N5 — + * ลำดับมาจาก query ด้านผู้เรียก คง relative order ไว้ทั้ง root และ children ในแม่เดียวกัน) แต่ละรายการ * /Dest ชี้หน้า + ตำแหน่ง yOffset — pdf-lib ไม่มี API ระดับสูงสำหรับ outline จึงต่อ * PDFDict เอง (Title เป็น PDFHexString UTF-16BE รองรับภาษาไทย) */ @@ -632,11 +633,16 @@ export async function GET(req: Request) { } // bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset) + // เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง) const bookmarkRows = await db .select() .from(bookmarks) .where(eq(bookmarks.fileId, fileId)) - .orderBy(asc(bookmarks.createdAt)); + .orderBy( + asc(bookmarks.pageIndex), + asc(bookmarks.yOffset), + asc(bookmarks.createdAt), + ); buildOutline(doc, bookmarkRows); const out = await doc.save(); diff --git a/app/api/bookmarks/export/route.ts b/app/api/bookmarks/export/route.ts new file mode 100644 index 0000000..c3b6aab --- /dev/null +++ b/app/api/bookmarks/export/route.ts @@ -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(); + 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}`, + }, + }); +} diff --git a/components/pdf/AnnotationSelectionLayer.tsx b/components/pdf/AnnotationSelectionLayer.tsx index 3ae33c1..5313e59 100644 --- a/components/pdf/AnnotationSelectionLayer.tsx +++ b/components/pdf/AnnotationSelectionLayer.tsx @@ -333,6 +333,10 @@ export default function AnnotationSelectionLayer({ // และซ่อนมุม (scale ไม่มีความหมายกับกล่องกว้างคงที่) — ย้ายกล่องใช้ขอบบน/ล่าง const isFixedTextbox = textboxData?.width !== undefined; + // แผงมี row ตัวควบคุมสไตล์หรือเหลือแค่ปุ่ม bookmark+ลบ (COMMENT/REDACTION) — + // กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between + const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData); + // มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct // (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2) const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [ @@ -498,11 +502,12 @@ export default function AnnotationSelectionLayer({ (สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT (ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา - จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) */} + จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) + กรณี w-fit ใช้ 240 เป็น upper bound ต่อ (แคบกว่า w-60 เสมอ — clamp หลวมเกินไม่มีผลร้าย) */} {editable && !disabled && (
)} -
-
+ {hasStyleControls &&
} +
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */} + {/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */} + {!hasStyleControls && ( +