# แผน 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" ?