# แผน 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 แสดง `` แทน 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" ? : ` (**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` — ฟอร์มเล็ก: `` + ปุ่ม "อัปโหลด" (client component แยกไฟล์ `components/UploadFileForm.tsx`, `useTransition` + ปุ่ม disabled ระหว่างอัป) - **Server Action `uploadFile`** ใน `app/actions/files.ts` (ไฟล์ใหม่): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไม่ว่าง → เขียน `public/uploads/.` (ชื่อ random กันชน/กัน traversal — ใช้ชื่อจาก randomUUID ไม่ใช่ชื่อผู้ใช้) → insert `files (title = ชื่อไฟล์เดิมที่ sanitize, pdf_url = /uploads/.)` → `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 ตรง, `` 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 ปลอดภัยอัตโนมัติ (`` ใช้ 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:** `` 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 ได้เหมือนก่อนแก้