eCourt-note/docs/PLAN-BOOKMARK-PDF.md
Your Name 460cd9c600
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
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/ลบติดกันคั่นเส้นแนวตั้ง
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 10:09:57 +07:00

18 KiB
Raw Blame History

แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7)

สถานะ: implement เสร็จสมบูรณ์ (2026-09-16) — ครบ 4 task, tsc ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า — page.drawText({ y: advanceLine(...) }) ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน page ทีหลัง, cycle guard ซ้ำ/หาย — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, ชื่อไฟล์ในหัวรายงานไม่ wrap — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย wrapTextByWidth เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า ไม่มีข้อความซ้อนทับเลย ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก อ้างอิง: requirement 5.3.15 (gap analysis docs/requirement/gap-analysis.md กลุ่ม B — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅) ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง docs/PLAN-FEAT.md §7 (N7 รายงานรายการ bookmark เป็น PDF) + ขยาย whitelist ใน CLAUDE.md ก่อน — เป็น task ที่ 1 ของแผนนี้ แล้วรันผ่าน /implement-feat N7

Context

  • 5.3.15 (⚠️): ตอนนี้มี export PDF ฝัง annotation + bookmark เป็น PDF outline (F8 — app/api/annotations/export/route.ts) + export CSV สรุป annotation (F16) — แต่ requirement ถามหา "เอกสาร PDF ที่เป็นรายงานรายการ bookmark" เอกสารแยกต่างหาก ไม่ใช่ไฟล์ต้นฉบับ
  • โครงสร้างพร้อมอยู่แล้วทั้งหมด: pdf-lib + fontkit (ใช้ใน F8), ฟอนต์ไทย public/fonts/Sarabun-Regular.ttf (embed ได้เลย), BookmarkDTO มี userName (N6) + โครงสร้างต้นไม้ parentId (N2) + ลำดับ N5 (pageIndex → yOffset → createdAt), wrapTextByWidth/encodeRFC5987 ใน utils/pdf-export.ts

การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-16)

  1. Entry point: ปุ่มไอคอนเล็กในแท็บ "ที่คั่นหน้า" ของ sidebar (ใน block ค้นหา N6) — จุดเดียว ไม่วางที่หน้าสรุป F15
  2. เลย์เอาต์: ต้นไม้ indent 3 ระดับ เหมือน sidebar เป๊ะ — เรียงตามลำดับหน้า (N5) แต่ละรายการ: ชื่อ + metadata (หน้า · ผู้บันทึก · วันที่บันทึก) + โน้ตใต้ชื่อ
  3. ขอบเขตข้อมูล: ทุก bookmark ของไฟล์เสมอ — ไม่ผูกกับเงื่อนไขค้นหา N6 ที่ผู้ใช้ทิ้งไว้

Design

D1 — Route app/api/bookmarks/export/route.ts (GET ?fileId=)

สร้างเอกสาร PDF ใหม่ล้วน (PDFDocument.create()) — ไม่โหลด/ไม่แตะไฟล์เอกสารต้นฉบับเลย (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป — ไม่เกี่ยว readPublicPdf):

  • validate + query ตาม pattern route export เดิม (F8/F16): fileId ผ่าน UUID → ไม่ผ่าน = 400 "fileId ไม่ถูกต้อง" · หา files ไม่เจอ = 404 "ไม่พบไฟล์นี้" · query bookmarks ทั้งไฟล์ orderBy asc(pageIndex), asc(yOffset), asc(createdAt) (ลำดับเดียวกับ searchBookmarks/N5)
  • ต้นไม้: build childrenOf + roots จาก array ที่เรียงแล้ว (เดินตามลำดับ = ลูกเรียงตาม N5 อัตโนมัติ) — orphan (parentId ไม่อยู่ใน set) → เลื่อนขึ้น root เหมือน sidebar/buildOutline · cycle guard (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action): DFS ด้วย seen ชุดเดียวร่วมทุก root กันเดินซ้ำ/วนไม่รู้จบ แล้วเติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ — ครอบทั้งวงจรปน root ปกติ (ไม่หายเงียบ) และวงจรล้วนทั้งไฟล์ (ออกมาแบนระดับ 1 ทั้งหมด)
  • ฟอนต์: embed public/fonts/Sarabun-Regular.ttf ด้วย fontkit (subset: true เหมือน F8) — จำเป็น (รายงานเป็นข้อความล้วน): ไม่มีไฟล์ = 500 ข้อความชัด "ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf" (ต่างจาก D5 ของ F8 ที่ optional เพราะ annotation ยังวาดรูปได้ — รายงานไม่มีฟอนต์ = ไร้ค่า) · ใช้ Regular ตัวเดียว — ลำดับชั้นทำด้วย indent + ขนาด ไม่ใช้ตัวหนา
  • เลย์เอาต์ A4 portrait (595.28×841.89pt) margin 48pt:
    • หัวรายงาน (หน้าแรกหน้าเดียว): "รายการที่คั่นหน้า" 16pt · ชื่อไฟล์ 12pt · "สร้างเมื่อ {วันที่ export} · N รายการ" 9pt สีเทา (#64748b) + เส้นคั่นใต้หัว
    • แถว bookmark (DFS ตามต้นไม้): ชื่อ — indent 14pt ต่อระดับ, ระดับ 1 = 13pt / ระดับ 2–3 = 11pt, wrap ด้วย wrapTextByWidth (ชื่อยาว) → บรรทัด metadata 9pt เทา: "หน้า {pageIndex+1} · {userName || "ไม่ทราบชื่อ"} · {createdAt format วันที่}" → ถ้ามีโน้ต: "โน้ต: …" 9pt wrap ใต้ metadata
    • วันที่: toLocaleDateString("th-TH", { timeZone: "Asia/Bangkok" }) (แสดง พ.ศ. อัตโนมัติ — วันที่อย่างเดียว ไม่มีเวลา ต่างจาก CSV ที่เป็น report เชิงอ่าน)
    • ระยะห่าง 8pt ต่อรายการ · 12pt ก่อนรายการระดับ 1 ที่ไม่ใช่ตัวแรก (แยกกลุ่มหัวข้อให้เห็น)
    • แบ่งหน้า: y เลย bottom margin → addPage วาดต่อ (หัวรายงานไม่ซ้ำ) — guard: ถ้าเพิ่งขึ้นหน้าใหม่แล้วแถวยังสูงเกินหน้า วาดต่ออยู่ดี (กัน infinite loop กับโน้ตยาวเกินหน้า) · จบแล้ววาด footer "หน้า n จาก N" กลางล่าง 9pt ทุกหน้า
    • 0 bookmark: ยังสร้าง PDF หน้าเดียวพร้อมข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ ทำนายผลได้)
  • Response: content-type: application/pdf + content-disposition: attachment; filename="bookmarks.pdf"; filename*=UTF-8''${encodeRFC5987(file.title + "-bookmarks.pdf")} (pattern เดียวกับ F8 -annotated.pdf / F16 -summary.csv)
  • Read-only ล้วน: ไม่ publish event ไม่ revalidatePath (เหมือน searchBookmarks/route export เดิม)

D2 — ปุ่มในแท็บ "ที่คั่นหน้า" ของ sidebar

components/sidebar/AnnotationSidebar.tsx — ใน block ค้นหา N6 (แสดงเฉพาะ tab === "bookmarks"):

  • ปุ่มไอคอน Download (lucide) shrink-0 วาดท้ายแถว toggle เงื่อนไข (ถัดจาก chevron) เป็น <a href={/api/bookmarks/export?fileId=${fileId}}> ลิงก์ธรรมดา — ไม่มี JS ไม่มี state (pattern เดียวกับปุ่ม Export CSV หน้าสรุป F16) — fileId เป็น prop อยู่แล้ว
  • title + aria-label = "ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น PDF" (บอก scope ตามการตัดสินใจ #3 — ไม่ขึ้นกับเงื่อนไขค้นหา) + FOCUS_RING_CLS + class coarse-36 (เป้าจิ้ม 36px บนจอสัมผัส — pattern ปุ่ม sidebar)
  • แสดงทุกมุมมองของแท็บ (ต้นไม้ / ผลการค้นหา) เพราะ block ค้นหาอยู่คงที่เหนือพื้นที่เนื้อหาเสมอ

Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)

ไฟล์ สิ่งที่แก้
docs/PLAN-FEAT.md §7 หัวข้อใหม่ N7 · รายงานรายการ bookmark เป็น PDF (format ตาม N5/N6 + อ้างแผนฉบับเต็ม docs/PLAN-BOOKMARK-PDF.md) + ปรับ out of scope ของ N6 ที่อ้าง gap 5.3.15 ให้ชี้มา N7
CLAUDE.md whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N7 + โครงสร้างไฟล์เพิ่ม app/api/bookmarks/export/route.ts (รายงาน PDF รายการ bookmark — N7)
  • docs/PLAN.md ไม่แตะ (ไม่มี schema/column ใหม่, ไม่มี invariant ใหม่ — เป็น route อ่านอย่างเดียวสร้างไฟล์ใหม่)
  • .claude/rules/*.md ไม่แตะ (route read-only ไม่เขียน DB ไม่เกี่ยว data-layer rule ใหม่; ไม่แตะ viewer/coordinates/realtime)

ลำดับ task + verify (ทุก task: npx tsc --noEmit ผ่าน)

# งาน ไฟล์หลัก Verify เพิ่มเติม
1 Docs sync N7 docs/PLAN-FEAT.md, CLAUDE.md สเปก §7 ครบตาม format N5/N6; whitelist + โครงสร้างไฟล์มี N7/route ใหม่
2 Route รายงาน PDF app/api/bookmarks/export/route.ts Playwright request: ไฟล์ที่มี bookmark ต้นไม้ 3 ระดับ → response %PDF + headers (filename* ไทย) + อ่านกลับด้วย pdf-lib ได้ page count ถูก; เนื้อหาตรวจด้วยการเปิดใน pdf.js viewer ชั่วคราว (วิธีเดิมที่ใช้ verify F8); เคส: fileId ไม่ผ่าน = 400, ไม่มีไฟล์ = 404, 0 bookmark = PDF หน้าเดียวข้อความว่าง, userName = '' = "ไม่ทราบชื่อ", ชื่อ/โน้ตยาว wrap + ขึ้นหน้าใหม่, footer หน้า n/N
3 ปุ่ม sidebar components/sidebar/AnnotationSidebar.tsx Playwright: กดปุ่มเกิด download ชื่อไฟล์ถูก; ปุ่มโผล่ทั้งมุมมองต้นไม้และผลค้นหา; จอ touch เป้า 36px; regression: ค้นหา N6 ยังทำงานครบ 4 เงื่อนไข
4 ปิดงาน — plan-reviewer ผ่านทุก invariant ที่ N7 แตะ (โดยเฉพาะ #1 non-destructive) + code-reviewer + vitest + tsc

Edge cases (ออกแบบไว้ล่วงหน้า)

  1. ชื่อ/โน้ตยาวเกินหน้า → wrapTextByWidth ตัดทีละตัวอักษร (ไทยไม่มีช่องว่าง) + ขึ้นหน้าใหม่; แถวเดียวสูงเกินหน้าเปล่าทั้งหน้า → วาดต่อ (guard infinite loop)
  2. orphan parentId (แม่หาย) → เลื่อนขึ้น root เหมือน sidebar และ buildOutline
  3. cycle ใน DB (แก้ตรง ๆ หลุด guard ของ action) → seen ร่วมทุก root กันเดินซ้ำ + โหนดที่ไม่มี root เข้าถึงถูกเติมเป็นระดับ 1 ท้ายรายการ — ทั้งวงจรล้วนและวงจรปน root ปกติ ไม่พัง ไม่ซ้ำ ไม่หาย
  4. userName = '' (row เดิมก่อน backfill N6) → "ไม่ทราบชื่อ"
  5. ลึกเกิน 3 ระดับใน DB (แก้ตรง ๆ) → DFS วาดตามจริง indent ต่อเนื่อง — ไม่พัง แค่ยาว (action กัน ≤ 3 อยู่แล้ว)
  6. 0 bookmark → PDF หน้าเดียว + "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ปุ่มไม่เคย error)
  7. ชื่อไฟล์/ชื่อ bookmark ภาษาไทย + อักขระพิเศษ → PDFHexString/drawText กับ Sarabun embed (pattern เดียวกับ TEXTBOX export F8) + filename filename* RFC 5987
  8. หน้าเอกสารเกินจริง (pageIndex พ้น page count ของเอกสาร) → ไม่เกี่ยว — รายงานไม่ผูกเอกสารต้นฉบับ แสดงเลขหน้าจาก DB ตรง ๆ

Out of scope (ไม่ทำในแผนนี้)

  • export ตามเงื่อนไขค้นหา N6 (ผู้ใช้เลือก: ทั้งไฟล์เสมอ)
  • รวม annotation ในรายงาน / เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV (F15/F16 ทำหน้าที่สรุป annotation อยู่แล้ว)
  • ตัวหนา (Sarabun-Bold.ttf) — ใช้ Regular ตัวเดียว (repo ไม่มีไฟล์ Bold; ลำดับชั้นด้วย indent + ขนาด)
  • export ข้ามหลายไฟล์ / document set — gap 5.3.10 (กลุ่ม C)
  • ตัวเลือกหน้ากระดาษ/แนวนอน/config หน้ารายงาน

Verification (ภาพรวม end-to-end)

  1. npm run dev — เปิดไฟล์ที่มี bookmark ต้นไม้ 3 ระดับกระจัดกระจายหลายหน้า → กดปุ่มในแท็บที่คั่นหน้า → ได้ไฟล์ PDF ชื่อ {title}-bookmarks.pdf
  2. เปิดรายงานตรวจ: ลำดับ = N5 (หน้า → ตำแหน่งบนหน้า → เวลา), indent 3 ระดับ, metadata ครบ หน้า/ผู้บันทึก/วันที่, โน้ตใต้ชื่อ, footer เลขหน้า, ข้อความไทย render ถูกด้วย Sarabun
  3. Regression: ค้นหา N6 + ต้นไม้ bookmark + export PDF F8 + Export CSV F16 ยังทำงานเหมือนเดิม