eCourt-note/docs/PLAN-IMAGE.md
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 9cc38835f0
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 2s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 40s
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
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

19 KiB
Raw Blame History

แผน 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):

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

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:

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 ได้เหมือนก่อนแก้