eCourt-note/docs/PLAN-IMAGE.md

133 lines
19 KiB
Markdown
Raw Normal View History

popup ซ้อนกัน/ไม่ปิดตอนกดปุ่ม tool + แท็บที่คั่นหน้ากลับไม่ได้ + งานค้างใน working tree: ไฟล์รูปภาพ annotate ได้ (N3) + อัปโหลดไฟล์ (N4): ส่วนที่ 1 — แก้ popup ตาม bug report ("กดแก้ไขแล้วไปกดที่ tool popup ของแก้ไขไม่ปิด/แสดงซ้อนกัน"): (1) ปุ่ม tool แบบ dropdown ทั้ง 5 (highlight/draw/shape/stamp/textbox) เรียก switchTool เสมอแม้เป็น tool เดิม — เดิมกดปุ่ม tool ที่ active อยู่จะ toggle เมนูอย่างเดียวไม่เรียก switchTool (ซึ่งเป็นที่เดียวที่เคลียร์ popup บนหน้า: editor/แผง selection/popover) ผลคือ popup แก้ไขค้างเปิด + dropdown เปิดซ้อนขึ้นมาอีกชั้น — ตอนนี้จำ intent เปิด/พับเมนูไว้ก่อนเรียก (switchTool ปิดเมนูทุกตัว) แล้วเปิดคืนเฉพาะที่ตั้งใจ; toggle พับเมนูและกลไกกลืนคลิก M4 เท่าเดิม (mousedown บนปุ่ม mark อยู่แล้ว) (2) switchTool ปิด popup bookmark ด้วย + openBookmarkComposer ปิดเมนูทั้ง 5 ก่อนเปิด popup (เดิมคีย์ลัด B ระหว่างเมนูเปิด = สอง popup ซ้อนกัน) (3) คลิกหน้าว่าง = ปิด popover ของ comment pin + เลือกรายการอื่น/goToAnnotation ปิด popover เดิมทิ้ง (เดิมปิด popover ได้แค่กด pin เดิม/Escape/สลับ tool) (4) COMMENT ที่เปิด popover อยู่ไม่ render กรอบ selection/แผงเครื่องมือ — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId ไฮไลต์เหมือนเดิม) (5) แก้แท็บ sidebar ตาม bug report ("เลือก Annotation แล้วจะกลับไป tabs bookmarks ไม่ได้"): effect เดิม dep รวม tab และบังคับ setTab ไปแท็บ annotations ทุกครั้งที่รันตราบ selectedId ไม่ null ทำให้กดแท็บที่คั่นหน้าแล้วถูกดึงกลับทันที — ใช้ ref จำ id ที่ประกาศแล้ว บังคับสลับเฉพาะตอน selectedId เพิ่งเปลี่ยน เคลียร์เป็น null ตอน deselect ให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้ ส่วน scroll แถวตอนเปิดแท็บหมายเหตุคงเดิม - sync rules/pdf-viewer.md: กติกาใหม่ popup บนหน้าเปิดได้ทีละชุดห้ามค้างเปิดซ้อนกัน (ปุ่ม dropdown เรียก switchTool เสมอ + วงจรปิด popover/แผง) และยกเว้น markMenuClosed ของคีย์ B ในกติกา M4 - verify: tsc ผ่าน, vitest 120/120, Playwright 18 เคสผ่าน (repro ตรงตามรายงาน: สร้าง textbox → แก้ไขข้อความ → กดปุ่ม tool เดิม → editor+แผงปิด+เมนูใช้ได้; popover pin ไม่ซ้อนแผง/ปิดด้วยคลิกหน้าว่าง; popup bookmark ปิดตอนสลับ tool; แท็บที่คั่นหน้ากลับได้) ส่วนที่ 2 — งานที่ค้างใน working tree ก่อนหน้า (ตามแผน docs/PLAN-IMAGE.md): - N3 รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด (viewer render img แทน canvas ไม่มี textLayer, scale contract เดียวกับ export) + TIFF หลายเฟรม = หลายหน้า (decode ผ่าน utif2 dynamic import → PNG blob URL ต่อเฟรม ฝั่ง client) + export PDF ฝังรูป (utils/image-to-pdf.ts: embedJpg/embedPng + EXIF rotate) + utils/file-kind.ts derive ชนิดไฟล์ + tests ของทั้งสอง utils - N4 อัปโหลดไฟล์ PDF/รูป ≤100MB ผ่านหน้ารายการ (components/UploadFileForm.tsx + app/actions/files.ts: validate + เขียนไฟล์ใหม่ที่ public/uploads/ เท่านั้น — invariant #1) - เอกสาร: docs/PLAN-IMAGE.md เพิ่มใหม่ + sync PLAN.md/PLAN-FEAT.md/CLAUDE.md/README/gap-analysis/data-layer rule Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:12:51 +07:00
# แผน 5.3.4 — รองรับไฟล์รูปภาพ (jpg/jpeg/png/tif) + อัปโหลดไฟล์ (N3 + N4)
> สถานะ: **implement เสร็จสมบูรณ์** (2026-09-15) — ทำครบทั้ง 6 task ตามลำดับ, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: blob revoke race, TIFF orientation 7 บนจอ, **EXIF 6/8 export anchor สลับที่ทำหน้าเปล่า** — re-verify ด้วยการ render ไฟล์ export กลับด้วย pdfjs แล้วภาพอยู่ในหน้าจริง, magic bytes ตอนอัปโหลด, unlink ไฟล์กำพร้าเมื่อ DB fail, EXIF parser ทนไฟล์เสี้ยว, cap ขนาด TIFF ฝั่ง server 4096px)
> อ้างอิง: requirement 5.3.4 (gap analysis `docs/requirement/gap-analysis.md` กลุ่ม C) + คำขอเพิ่มอัปโหลดแบบง่าย
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (N3/N4) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task ที่ 1 ของแผนนี้
## Context
Requirement 5.3.4 กำหนดรูปแบบเอกสาร: PDF + รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย — ปัจจุบันรองรับ PDF เท่านั้น และไฟล์เข้าระบบได้ทางเดียวคือ seed script เป้าหมาย: อัปโหลด/เปิดไฟล์รูปภาพ → annotate ครบทุกชนิด → realtime → export PDF/CSV ได้ เหมือน PDF ทุกประการ
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว)
1. **สถาปัตยกรรม "ไม่แปลงก่อนใช้":** รูปคือ "หน้าเอกสาร" — viewer แสดง `<img>` แทน canvas ของ pdfjs, layers ซ้อนบนรูปตรง ๆ, annotation เก็บเป็นพิกัด % ใน DB เหมือนเดิม — PDF เกิดจริงเฉพาะไฟล์ export (สร้างใหม่ใน memory ตาม invariant #1)
2. **TIFF: รองรับเลยด้วย UTIF.js** (`utif2` — decode รันไทม์ฝั่ง client แสดงผล + ฝั่ง server transcode PNG ตอน export), TIFF หลายเฟรม = หลายหน้า
3. **toolbar คงทุก tool** กับทุกเอกสาร — ไม่ซ่อน HIGHLIGHT บนรูป (no-op แบบเดียวกับ PDF สแกน มี hint "ภาพสแกน" โผล่เอง)
4. **อัปโหลดแบบง่าย:** ฟอร์มเดียวบนหน้า `/` รับทั้ง PDF และรูป — ไม่มีหน้าจัดการ/ลบ/drag-drop/progress
5. **EXIF-rotated JPEG:** จัดการตอน export ใน scope นี้ (parse APP1 orientation 5–8 → swap ขนาดหน้า + drawImage rotate — ไม่งั้น export ได้สัดส่วนบิดต่างจากจอ)
6. **รับ `.tiff` (สะกดยาว) ด้วย**, หน้ารูป normalize กว้าง **595** (scale contract — zoom %/fit-width/fontSize% ทำงานเหมือนหน้า A4 ทุกประการ)
## หลักตั้งต้น (จากการสำรวจโค้ด + spot-check)
- ทุก layer เหนือ canvas เป็น generic ต่อ "หน้า W×H + พิกัด %" — สิ่งที่ PDF-specific มีแค่ 3 จุด: (1) load effect `PDFViewer.tsx:1320-1366` (2) canvas+textLayer ใน `PdfPage` 541-1075 (3) `PDFDocument.load` ที่ `app/api/annotations/export/route.ts:229` — แทรก branch เพียง 3 จุด ที่เหลือ reuse
- spot-check แล้ว: `PDFViewer.tsx:3462` = `{mounted && pdfjs && doc ? (`, `seed-samples.mjs:18` = filter `.endsWith(".pdf")`, มี vitest tests ที่ `tests/utils/*.test.ts` (เพิ่ม test ตาม pattern นี้ได้)
- `lib/server-pdf.ts` path guard เป็น path-containment ล้วน (ไม่เช็ค extension) — ใช้กับรูปได้เลย
- pdf-lib ^1.17.1 มี `embedJpg`/`embedPng` แล้ว, ไม่มี TIFF embed → ต้อง transcode
- deps ใหม่: `utif2` + `@types/utif`, `pngjs` + `@types/pngjs` (pure JS ทั้งคู่)
## Design
### D1. ชนิดไฟล์ — derive จาก extension (ไม่เพิ่ม DB column)
ไฟล์ใหม่ `utils/file-kind.ts` (pure, ใช้ได้ทั้ง server/client):
```ts
export type FileKind = "pdf" | "image";
export type ImageFormat = "jpeg" | "png" | "tiff";
export function fileKindFromUrl(url: string): FileKind; // .jpg/.jpeg/.png/.tif/.tiff → "image" (case-insensitive)
export function imageFormatFromUrl(url: string): ImageFormat | null;
export const SAMPLE_FILE_EXTENSIONS: readonly string[]; // [".pdf",".jpg",".jpeg",".png",".tif",".tiff"]
export function imageContentTypeFromUrl(url: string): string; // image/jpeg | image/png | image/tiff
```
เหตุผล: extension เก็บอยู่ใน `pdf_url` อยู่แล้ว (single source of truth, seed idempotent อิง URL) — ไม่ต้อง migrate schema / drizzle push / backfill → `docs/PLAN.md` §3 ไม่ต้องแตะ ผู้ใช้ 4 จุด: PDFViewer (derive ใน client จาก `file.pdfUrl` — `app/annotate/[id]/page.tsx` ไม่ต้องแก้), `app/page.tsx` (badge), export route, seed script (.mjs duplicate list + comment ให้ sync)
### D2. Viewer — `PageContent` union + branch 3 จุดใน `components/pdf/PDFViewer.tsx`
```ts
type PageContent =
| { kind: "pdf"; pdfjs: typeof import("pdfjs-dist"); doc: import("pdfjs-dist").PDFDocumentProxy }
| { kind: "image"; src: string | null }; // null = เฟรม decode ไม่ได้ (placeholder เทา)
type ImagePage = { src: string | null; width: number; height: number }; // normalize กว้าง 595
```
1. **load effect (1320-1366):** state ใหม่ `imagePages: ImagePage[] | null` คู่ขนาน `doc`/`pdfjs` — branch บนสุดของ effect เดิม:
- jpeg/png: `new Image()` + `img.src = file.pdfUrl` + `await img.decode()` → pageSizes จาก `naturalWidth/Height` (browser คิด EXIF orientation แล้ว) → normalize 595
- tiff: `fetch(file.pdfUrl)` → **`await import("utif2")`** (dynamic import เฉพาะ tif ตาม pattern invariant #6) → `UTIF.decode` วนทุก IFD → `decodeImage`+`toRGBA8` → canvas → `toBlob("image/png")` → `URL.createObjectURL` ต่อเฟรม + **revoke ทุก blob URL ใน cleanup** (กัน leak ตอนสลับไฟล์); TIFF orientation tag 274 หมุนตอนวาง canvas; cap ด้านยาว decoded frame 4096px (resample ใน canvas — annotation เป็น % ไม่กระทบ)
- pdf: โค้ดเดิมไม่แตะ
2. **mount condition (3462):** สร้าง `PageContent` จาก state ใด state หนึ่งแทน `pdfjs && doc`
3. **PdfPage (541-1075):** prop `pdfjs`+`doc` รวมเป็น `content` — z-1: `kind==="pdf" ? <canvas> : <img className="absolute inset-0 z-[1] pointer-events-none" draggable={false}>` (**pointer-events:none เหมือน canvas ตาม invariant #3**); pdfjs render effect (644-701) guard `if (content.kind !== "pdf") return`; textLayer div render เฉพาะ pdf (conditional render ไม่ย้ายโหนด — DOM order คงเดิมตาม invariant #3); effect เล็ก `onTextLayerRendered(pageNumber-1, 0)` ให้ hint "ภาพสแกน" + `useCustomSelection` bail ทำงานเอง
ไม่ต้องแตะเลย: virtualization, zoom/fit-width, `basePageWidth`, deep link `?page`/`?ann`, bookmark scroll, InkLayer/TextboxStampLayer/AnnotationSelectionLayer/pins/tooltip/hit-test, sidebar, Server Actions, SSE, summary, CSV
### D3. `/api/pdf` — รูปโหลด static `pdfUrl` ตรง ๆ + defensive branch
รูปไม่ต้องผ่าน `/api/pdf` (ไม่มี ToUnicode ให้ซ่อม, ได้ static cache ฟรี + Content-Type ถูก) — แต่เพิ่ม guard ใน `app/api/pdf/route.ts` หลัง `readPublicPdf`: ถ้า `fileKindFromUrl === "image"` คืน bytes ดิบ + content-type ถูก (กัน stray call หลุดเข้า `fixPdfActualText` ที่จะ throw)
### D4. Export — branch ก่อน `PDFDocument.load` แล้ว reuse pipeline 100%
ที่ `app/api/annotations/export/route.ts:229`:
```ts
const doc = fileKindFromUrl(file.pdfUrl) === "image"
? await imageDocFromBytes(srcBytes, file.pdfUrl)
: await PDFDocument.load(srcBytes);
```
ไฟล์ใหม่ `utils/image-to-pdf.ts` (server-only): `PDFDocument.create()` → jpeg `embedJpg` / อื่น ๆ `embedPng` → `addPage([595, 595·h/w])` + `drawImage` เต็มหน้า → คืน doc แล้ววาด annotation + bookmark outline ด้วย pipeline เดิมทั้งหมด (rotation=0 → `displaySize`/`displayToPdfPoint`/`pxPoint` ใช้ได้ทันที, `doc.getPage(pageIndex)` map ตรง 1 เฟรม = 1 หน้า)
- **JPEG EXIF orientation 5–8:** parse APP1 เอง ~30 บรรทัด → swap กว้าง-สูง + `drawImage` ด้วย `rotate` radians
- **TIFF:** `tiffFramesToPng(bytes)` = utif2 decode ทุก IFD → toRGBA8 → pngjs `PNG.sync.write` → embedPng (เฟรม decode พัง = หน้าขาว + console.error เพื่อให้ pageIndex map ตรง)
### D5. อัปโหลดแบบง่าย (N4)
- **UI:** หน้า `app/page.tsx` — ฟอร์มเล็ก: `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ปุ่ม "อัปโหลด" (client component แยกไฟล์ `components/UploadFileForm.tsx`, `useTransition` + ปุ่ม disabled ระหว่างอัป)
- **Server Action `uploadFile`** ใน `app/actions/files.ts` (ไฟล์ใหม่): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (ชื่อ random กันชน/กัน traversal — ใช้ชื่อจาก randomUUID ไม่ใช่ชื่อผู้ใช้) → insert `files (title = ชื่อไฟล์เดิมที่ sanitize, pdf_url = /uploads/<uuid>.<ext>)` → `revalidatePath("/")`
- **`next.config.mjs`:** `experimental: { serverActions: { bodySizeLimit: "100mb" } }` (Next 14 default 1MB)
- seed script จัดการเฉพาะ `/samples/%` → uploads ไม่โดน seed ลบ
- Out of scope: ลบ/แก้ชื่อไฟล์ใน UI, drag-drop, progress bar, multi-file, auth ผูกเจ้าของไฟล์
### D6. Seed + หน้ารายการ
- `scripts/seed-samples.mjs:18`: filter เป็น `SAMPLE_FILE_EXTENSIONS.some(...)` + แก้ comment บรรทัด 2 ให้ตรง
- `app/page.tsx`: ไอคอน `FileImage` (lucide, fallback `Image`) + badge `PDF/JPG/PNG/TIFF` ต่อ row
## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
| ไฟล์ | สิ่งที่แก้ |
|---|---|
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N3 รองรับไฟล์รูปภาพ** + **N4 อัปโหลดไฟล์แบบง่าย** (format ตาม N1/N2: ข้อมูล/การโหลด/การแสดง/Export/Realtime/Out of scope ต่อฟีเจอร์) |
| `CLAUDE.md` | (1) whitelist ขอบเขตเพิ่ม N3/N4 (2) ลบข้อ "ไม่มีหน้า upload" ออกจาก Out of Scope (3) invariant #1 "ไฟล์ PDF ต้นฉบับ" → "ไฟล์ต้นฉบับ (PDF/รูปภาพ)" (4) โครงสร้างไฟล์เพิ่ม `utils/file-kind.ts`, `utils/image-to-pdf.ts`, `app/actions/files.ts`, `components/UploadFileForm.tsx` |
| `docs/PLAN.md` | §3 ไม่แตะ (ไม่มี schema change); §0 A5 "ไม่มีหน้า upload" → อัปเดตเป็นมี upload แบบง่าย (N4) |
| `.claude/rules/pdf-viewer.md` | แก้ bullet "โหลด PDF ผ่าน /api/pdf เสมอ" → เพิ่มข้อยกเว้นรูปโหลด static ตรง, `<img>` z-1 pointer-events:none, `onTextLayerRendered(pi, 0)`, normalize 595, utif2 dynamic import + revoke blob URL |
| `.claude/rules/data-layer.md` | `files.pdf_url` เก็บ path รูปได้ — derive ชนิดผ่าน `utils/file-kind.ts` เท่านั้น (ไม่มี column kind), TIFF หลายเฟรม = pageIndex ต่อเฟรม, Server Action `uploadFile` เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น (ต้องไม่แตะไฟล์เดิม) |
| `README.md` | ส่วน seed: extension ที่รับ + วิธีอัปโหลดผ่านหน้า `/` |
## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน + agent plan-reviewer)
| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม |
|---|---|---|---|
| 1 | Docs sync N3/N4 + `utils/file-kind.ts` + unit test | `docs/PLAN-FEAT.md`, `CLAUDE.md`, `docs/PLAN.md` §0, `utils/file-kind.ts`, `tests/utils/file-kind.test.ts` | vitest ผ่านทุก case (uppercase/ไม่มี extension/.tiff ยาว); N3/N4 spec ครบตาม format §7 |
| 2 | Upload (Server Action + ฟอร์ม + next.config) | `app/actions/files.ts`, `components/UploadFileForm.tsx`, `app/page.tsx`, `next.config.mjs`, `.claude/rules/data-layer.md` | Playwright: อัปโหลด PDF + jpg → row ใหม่โผล่ + เปิด PDF ได้เหมือนเดิม; ไฟล์ >100MB ถูกปฏิเสธพร้อมข้อความ; ชื่อไฟล์พิเศษ (ภาษาไทย/`../`) ไม่หลุดออกนอก `public/uploads/` |
| 3 | Viewer jpg/png (`PageContent`) + `/api/pdf` defensive branch | `components/pdf/PDFViewer.tsx`, `app/api/pdf/route.ts`, `.claude/rules/pdf-viewer.md` | อัปโหลด jpg/png แล้วเปิด: fit-width เต็มจอ, annotate ครบ DRAW/SHAPE/TEXTBOX/STAMP/REDACTION/COMMENT + bookmark, HIGHLIGHT ยังอยู่ + hint "ภาพสแกน", 2 แท็บ realtime, zoom 0.5-3 annotation ไม่เบี้ยว; **regression: PDF มี text highlight ได้เหมือนเดิม + PDF หลายสิบหน้า virtualization ปกติ**; จอ coarse (Playwright touch) วาดบนรูปได้ไม่กลายเป็น scroll |
| 4 | TIFF ฝั่ง client (utif2) | `components/pdf/PDFViewer.tsx`, `utils/tiff-client.ts`, `package.json` (`utif2`, `@types/utif`) | อัปโหลด tif หลายเฟรม = หลายหน้า, เลื่อนลื่น, `?page=N` + bookmark กระโดดถูกเฟรม, สลับไฟล์หลายรอบ memory ไม่โต (blob revoke), เฟรมพัง = placeholder เทาแต่ไฟล์ไม่พังทั้งเล่ม |
| 5 | Export PDF จากรูป + EXIF | `app/api/annotations/export/route.ts`, `utils/image-to-pdf.ts` | annotate ครบทุกชนิดบน jpg → export → เปิดด้วย pdf.js viewer: หน้า 595×สัดส่วนรูป, annotation ตรงตำแหน่ง, REDACTION ทึบ, COMMENT sticky เปิด popup, bookmark outline กระโดดถูกหน้า; JPEG เอียง 90° (fixture สร้างเอง) export ตรงกับจอ; **hash ต้นฉบับใน public/ ไม่เปลี่ยน** (`certutil -hashfile` ก่อน/หลัง); export PDF/CSV เดิมได้เหมือนเดิม (regression) |
| 6 | TIFF ฝั่ง server ตอน export + ปิดงาน | `utils/image-to-pdf.ts` (`tiffFramesToPng`), `package.json` (`pngjs`, `@types/pngjs`) | export tif 3 เฟรม → PDF 3 หน้า + annotation บนเฟรม 2/3 ตกหน้าถูก + outline; hash ต้นฉบับไม่เปลี่ยน; plan-reviewer ผ่านทุก invariant ที่ N3/N4 แตะ |
## Edge cases (ออกแบบไว้ล่วงหน้า)
1. **EXIF JPEG:** viewer ปลอดภัยอัตโนมัติ (`<img>` ใช้ orientation เป็น default, `naturalWidth/Height` = หลังหมุน) — จุดรั่วคือ export (pdf-lib ฝัง JPEG ดิบ) → จัดการใน D4; PNG ไม่มีปัญหา
2. **TIFF orientation tag 274:** UTIF ไม่หมุนให้ — หมุนเอง client (ตอนวาง canvas) + server (transpose RGBA ก่อน PNG encode) ให้สองฝั่งตรงกัน
3. **Retina:** `<img>` browser render ที่ dpr เต็ม — เทียบเท่า canvas×dpr ของ pdfjs ไม่ต้องทำอะไร
4. **รูปใหญ่ (สแกน 600dpi ~5000px):** normalize 595 ทำให้ zoom clamp 0.5-3 ใช้ได้ + cap 4096px ตอน decode กัน memory
5. **TIFF compression ที่ utif2 ไม่รอง (CCITT G3/G4):** เฟรมนั้น placeholder เทาแต่เอาขนาดจาก IFD header → page count/scroll/bookmark เสถียร (จดไว้ใน out of scope ของ N3)
6. **stray `/api/pdf?fileId=<รูป>`:** defensive branch คืน bytes ดิบ — ไม่หลุดเข้า `fixPdfActualText`
7. **อัปโหลดชื่อซ้ำ/ชื่อแปลก:** uuid เป็นชื่อไฟล์บนดิสก์ + sanitize title — ไม่ชนกัน ไม่ traversal
## Verification (ภาพรวม end-to-end)
1. `npm run dev` + `npm run seed-samples` — รายการเดิมไม่หาย (dev DB ผู้ใช้ใช้ร่วม — ห้าม reset DB)
2. Playwright: อัปโหลด `sample.jpg` → เปิด → ทดสอบเต็มวงจร (วาดเส้น/รูปทรง/TEXTBOX/STAMP/COMMENT/bookmark) → แท็บที่สองเห็น realtime → export PDF → เปิดด้วย pdf.js viewer ตรวจตำแหน่ง + `certutil -hashfile` ต้นฉบับก่อน/หลังเทียบกัน
3. เช่นเดียวกับ `.tif` หลายเฟรม + `?page=2` deep link
4. Regression PDF เดิม: highlight ข้อความ + export ได้เหมือนก่อนแก้