popup ซ้อนกัน/ไม่ปิดตอนกดปุ่ม tool + แท็บที่คั่นหน้ากลับไม่ได้ + งานค้างใน working tree: ไฟล์รูปภาพ annotate ได้ (N3) + อัปโหลดไฟล์ (N4):
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

ส่วนที่ 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>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-15 14:12:51 +07:00
parent 0c4af09e85
commit 9cc38835f0
25 changed files with 1269 additions and 77 deletions

54
utils/file-kind.ts Normal file
View file

@ -0,0 +1,54 @@
// ชนิดไฟล์เอกสาร — derive จาก extension ของ path ที่เก็บใน files.pdf_url (N3)
// ไม่มี column kind ใน DB: extension คือ single source of truth (seed idempotent อิง URL)
// pure module — ใช้ได้ทั้ง server และ client
export type FileKind = "pdf" | "image";
export type ImageFormat = "jpeg" | "png" | "tiff";
const IMAGE_EXTENSIONS: Record<string, ImageFormat> = {
".jpg": "jpeg",
".jpeg": "jpeg",
".png": "png",
".tif": "tiff",
".tiff": "tiff",
};
/** extension (ตัวเล็ก พร้อมจุดนำหน้า) ของ path หลังตัด query string — ไม่มี = "" */
function extOf(url: string): string {
const clean = url.split(/[?#]/, 1)[0];
const slash = Math.max(clean.lastIndexOf("/"), clean.lastIndexOf("\\"));
const name = slash >= 0 ? clean.slice(slash + 1) : clean;
const dot = name.lastIndexOf(".");
return dot <= 0 ? "" : name.slice(dot).toLowerCase();
}
export function imageFormatFromUrl(url: string): ImageFormat | null {
return IMAGE_EXTENSIONS[extOf(url)] ?? null;
}
/** ไม่รู้จัก extension → "pdf" ตามพฤติกรรมเดิม (ก่อน N3 ทุกไฟล์เปิดเป็น PDF) */
export function fileKindFromUrl(url: string): FileKind {
if (imageFormatFromUrl(url)) return "image";
return "pdf";
}
/** whitelist extension ที่ seed-samples sync จาก public/samples/ (seed .mjs มีสำเนา — sync กัน) */
export const SAMPLE_FILE_EXTENSIONS: readonly string[] = [
".pdf",
".jpg",
".jpeg",
".png",
".tif",
".tiff",
];
const IMAGE_CONTENT_TYPES: Record<ImageFormat, string> = {
jpeg: "image/jpeg",
png: "image/png",
tiff: "image/tiff",
};
export function imageContentTypeFromUrl(url: string): string {
const format = imageFormatFromUrl(url);
return format ? IMAGE_CONTENT_TYPES[format] : "application/octet-stream";
}

252
utils/image-to-pdf.ts Normal file
View file

@ -0,0 +1,252 @@
// N3 — สร้าง PDF document ใหม่จากไฟล์รูปภาพ (jpeg/png) สำหรับ export route
// ต้นฉบับไม่ถูกแตะ: อ่าน bytes จาก public/ อย่างเดียวแล้ว embed ลง PDF ใหม่ในหน่วยความจำ (invariant #1)
// หลังสร้างเสร็จ pipeline วาด annotation/bookmark outline ของ route ใช้ได้ทันที
// (rotation=0, page.getSize() = [595, h] ตรง scale contract ฝั่ง viewer)
import { degrees, PDFDocument } from "pdf-lib";
import { PNG } from "pngjs";
import UTIF from "utif2";
import { imageFormatFromUrl } from "./file-kind";
/** กว้างหน้า normalize — ต้องเท่ากับ IMAGE_PAGE_WIDTH ใน PDFViewer.tsx
* (scale contract: zoom %/พิกัด % ของ annotation ตรงกันทั้งจอและไฟล์ export) */
export const IMAGE_PAGE_WIDTH = 595;
/** อ่าน EXIF orientation (APP1) ของ JPEG → 1–8 (ไม่มี EXIF/อ่านไม่ได้ = 1)
* pdf-lib embedJpg ฝัง JPEG ดิบไม่สน EXIF แต่ browser (<img>) ใช้ orientation เป็น default
* → ค่า 3/6/8 ต้องหมุนตอน drawImage เอง ไม่งั้นหน้า export สัดส่วนบิดต่างจากที่เห็นบนจอ */
export function jpegExifOrientation(bytes: Buffer): number {
// ทนไฟล์เสี้ยว: อะไรก็ตามที่อ่านเกิน buffer/ผิดรูปแบบ = ไม่มี EXIF (คืน 1) ห้ามโยนออกไป
// ทำให้ export route พัง (code-review: RangeError จาก APP1 ถูกตัดกลางคัน)
try {
return jpegExifOrientationUnsafe(bytes);
} catch {
return 1;
}
}
function jpegExifOrientationUnsafe(bytes: Buffer): number {
// เดิน marker ของ JPEG: SOI → ... จนเจอ APP1 (FFE1) ที่ขึ้นต้น "Exif\0\0" (หยุดที่ SOS/EOI)
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1;
let o = 2;
while (o + 4 <= bytes.length) {
if (bytes[o] !== 0xff) return 1;
if (bytes[o + 1] === 0xff) {
o++; // fill bytes ก่อน marker (JPEG spec อนุญาต) — ไม่ข้ามแล้วเลข length เพี้ยน
continue;
}
const marker = bytes[o + 1];
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
o += 2; // standalone marker ไม่มี length
continue;
}
if (marker === 0xd9 || marker === 0xda) return 1; // จบ header แล้ว ไม่มี EXIF
const len = bytes.readUInt16BE(o + 2);
if (len < 2) return 1; // length ไม่สมเหตุสมผล — กันวนลูป
if (marker === 0xe1 && len >= 16 && bytes.toString("latin1", o + 4, o + 10) === "Exif\0\0") {
const tiff = o + 10;
const le = bytes.toString("latin1", tiff, tiff + 2) === "II";
const u16 = (p: number) => (le ? bytes.readUInt16LE(p) : bytes.readUInt16BE(p));
const u32 = (p: number) => (le ? bytes.readUInt32LE(p) : bytes.readUInt32BE(p));
if (u16(tiff + 2) !== 42) return 1;
const ifd = tiff + u32(tiff + 4);
if (ifd + 2 > bytes.length) return 1;
const count = u16(ifd);
for (let i = 0; i < count && ifd + 2 + (i + 1) * 12 <= bytes.length; i++) {
const entry = ifd + 2 + i * 12;
if (u16(entry) === 274) {
const value = u16(entry + 8);
return value >= 1 && value <= 8 ? value : 1;
}
}
return 1;
}
o += 2 + len;
}
return 1;
}
/** หมุน/mirror RGBA buffer ตาม EXIF/TIFF orientation ครบทุกค่า 1–8 ตามมาตรฐาน
* (3=180°, 6=90°CW, 8=270°CW, 2/4=flip แนวนอน/ตั้ง, 5=transpose, 7=anti-transpose)
* ต้องให้ผลตรงกับ rgbaToCanvas ของ utils/tiff-client.ts ฝั่ง viewer เสมอ
* (export เพื่อ unit test) */
export function rotateRgba(
rgba: Uint8Array,
w: number,
h: number,
orientation: number,
): { data: Uint8Array; width: number; height: number } {
if (orientation < 2 || orientation > 8) return { data: rgba, width: w, height: h };
const swap = orientation >= 5;
const nw = swap ? h : w;
const nh = swap ? w : h;
const out = new Uint8Array(rgba.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let dx = x;
let dy = y;
switch (orientation) {
case 2:
dx = w - 1 - x;
break;
case 3:
dx = w - 1 - x;
dy = h - 1 - y;
break;
case 4:
dy = h - 1 - y;
break;
case 5:
dx = y;
dy = x;
break;
case 6:
dx = h - 1 - y;
dy = x;
break;
case 7:
dx = h - 1 - y;
dy = w - 1 - x;
break;
case 8:
dx = y;
dy = w - 1 - x;
break;
}
const src = (y * w + x) * 4;
const dst = (dy * nw + dx) * 4;
out[dst] = rgba[src];
out[dst + 1] = rgba[src + 1];
out[dst + 2] = rgba[src + 2];
out[dst + 3] = rgba[src + 3];
}
}
return { data: out, width: nw, height: nh };
}
/** JPEG export ทำได้แค่ rotate (pdf-lib ไม่มี mirror ตอน drawImage) → exact สำหรับ
* 1/3/6/8 ส่วน mirror 2/4/5/7 (พบน้อยมากในไฟล์สแกน — มักเจอจากกล้องหน้ามือถือ)
* ประมาณด้วยการหมุนของคู่ตรงข้าม (5→8, 7→6) หรือแสดงตรง ๆ (2/4) —
* known limitation จดใน PLAN-FEAT.md §7 N3 */
function approxJpegOrientation(orientation: number): number {
if (orientation === 5) return 8;
if (orientation === 7) return 6;
if (orientation === 2 || orientation === 4) return 1;
return orientation;
}
function rgbaToPng(data: Uint8Array, width: number, height: number): Buffer {
const png = new PNG({ width, height });
Buffer.from(data.buffer, data.byteOffset, data.byteLength).copy(png.data);
return PNG.sync.write(png);
}
/** cap ด้านยาว decoded frame ให้เท่าฝั่ง client (MAX_FRAME_DIMENSION ของ tiff-client.ts)
* — สแกน 600dpi ~5000×7000 จะกิน RGBA ~140MB ต่อเฟรม คูณจำนวนเฟรมตอน export
* downscale แบบ nearest ก็พอเพราะเป็นแค่เรือ่ง guard memory ไม่ใช่คุณภาพหลัก
* (annotation เป็น % ของหน้า — ย่อภาพแล้วตำแหน่งไม่กระทบ) */
const MAX_TIFF_EXPORT_DIMENSION = 4096;
function downscaleRgba(
data: Uint8Array,
w: number,
h: number,
): { data: Uint8Array; width: number; height: number } {
const long = Math.max(w, h);
if (long <= MAX_TIFF_EXPORT_DIMENSION) return { data, width: w, height: h };
const k = MAX_TIFF_EXPORT_DIMENSION / long;
const nw = Math.max(1, Math.round(w * k));
const nh = Math.max(1, Math.round(h * k));
const out = new Uint8Array(nw * nh * 4);
for (let y = 0; y < nh; y++) {
const sy = Math.min(h - 1, Math.round(y / k));
for (let x = 0; x < nw; x++) {
const sx = Math.min(w - 1, Math.round(x / k));
const src = (sy * w + sx) * 4;
const dst = (y * nw + x) * 4;
out[dst] = data[src];
out[dst + 1] = data[src + 1];
out[dst + 2] = data[src + 2];
out[dst + 3] = data[src + 3];
}
}
return { data: out, width: nw, height: nh };
}
/** TIFF decode ทุกเฟรม (IFD) → PNG buffer ต่อเฟรม (TIFF หลายเฟรม = หลายหน้า)
* เฟรมที่ decode พัง (เช่น compression ที่ utif2 ไม่รอง) = หน้าขาวสัดส่วนตาม header
* เพื่อให้จำนวนหน้า/pageIndex map ตรงกับฝั่ง viewer เสมอ */
function tiffFramesToPng(bytes: Buffer): { bytes: Buffer; jpeg: boolean }[] {
const ifds = UTIF.decode(bytes);
// เอาเฉพาะ IFD ที่เป็นเฟรมรูป — เรียงลำดับ/เงื่อนไขเดียวกับ decodeTiffPages ฝั่ง client
const frames = ifds.filter(
(ifd) => Array.isArray(ifd.t256) && Array.isArray(ifd.t257),
);
return frames.map((ifd) => {
const headerW = Array.isArray(ifd.t256) ? Number(ifd.t256[0]) || 1 : 1;
const headerH = Array.isArray(ifd.t257) ? Number(ifd.t257[0]) || 1 : 1;
try {
UTIF.decodeImage(bytes, ifd);
const rgba = UTIF.toRGBA8(ifd);
const orientation = Array.isArray(ifd.t274) ? Number(ifd.t274[0]) || 1 : 1;
const scaled = downscaleRgba(rgba, ifd.width, ifd.height);
const rotated = rotateRgba(scaled.data, scaled.width, scaled.height, orientation);
return { bytes: rgbaToPng(rotated.data, rotated.width, rotated.height), jpeg: false };
} catch (err) {
console.error("decode เฟรม TIFF ตอน export ไม่ได้ — ใส่หน้าขาวแทน", err);
// หน้าขาวจอง buffer ที่ขนาด capped พอ (สัดส่วนเดียวกับ header — หน้า PDF ได้ ratio เดิม)
const capped = downscaleRgba(new Uint8Array(0), headerW, headerH);
const white = new Uint8Array(capped.width * capped.height * 4).fill(255);
return { bytes: rgbaToPng(white, capped.width, capped.height), jpeg: false };
}
});
}
/** สร้าง PDF ใหม่ที่มีรูปเต็มหน้าทุกเฟรม (TIFF หลายเฟรม = หลายหน้า)
* คืน PDFDocument ให้ export route วาด annotation ต่อด้วย pipeline เดิม */
export async function imageDocFromBytes(bytes: Buffer, url: string): Promise<PDFDocument> {
const format = imageFormatFromUrl(url);
if (!format) throw new Error(`ไฟล์ ${url} ไม่ใช่รูปภาพที่รองรับ`);
const doc = await PDFDocument.create();
const frames: { bytes: Buffer; jpeg: boolean }[] =
format === "tiff" ? tiffFramesToPng(bytes) : [{ bytes, jpeg: format === "jpeg" }];
// guard เดียวกับ decodeTiffPages ฝั่ง client — PDF 0 หน้าจะทำให้ getPage(-1) throw ทีหลัง
if (frames.length === 0) throw new Error("ไม่พบเฟรมรูปในไฟล์ TIFF");
for (const frame of frames) {
const image = frame.jpeg ? await doc.embedJpg(frame.bytes) : await doc.embedPng(frame.bytes);
// PNG ไม่มี EXIF; TIFF ถูกหมุนครบทุกค่าตั้งแต่ tiffFramesToPng แล้ว — เหลือ JPEG (approx)
const orientation = frame.jpeg ? approxJpegOrientation(jpegExifOrientation(frame.bytes)) : 1;
const quarter = orientation >= 5 && orientation <= 8; // หมุน 90° → สลับกว้าง-สูงของภาพที่แสดง
// ขนาดหน้า: กว้าง 595 เสมอ สูงตามสัดส่วน "ภาพหลังหมุน" (ตรงกับที่แสดงบนจอ)
const storedW = image.width;
const storedH = image.height;
const displayedRatio = quarter ? storedW / storedH : storedH / storedW;
const pageW = IMAGE_PAGE_WIDTH;
const pageH = pageW * displayedRatio;
const page = doc.addPage([pageW, pageH]);
if (orientation === 6) {
// 90°CW: anchor มุมล่างซ้าย วาดแล้วหมุน -90° รอบ anchor (pdf-lib หมุนรอบ x,y —
// bbox ที่ได้ต้องครอบหน้าพอดี ตรวจด้วยการคูณ matrix จาก content stream จริง)
page.drawImage(image, {
x: 0, y: pageH, width: pageH, height: pageW, rotate: degrees(-90),
});
} else if (orientation === 8) {
// 270°CW: anchor มุมล่างขวา วาดแล้วหมุน +90°
page.drawImage(image, {
x: pageW, y: 0, width: pageH, height: pageW, rotate: degrees(90),
});
} else if (orientation === 3) {
// 180°: anchor มุมบนขวา
page.drawImage(image, {
x: pageW, y: pageH, width: pageW, height: pageH, rotate: degrees(180),
});
} else {
page.drawImage(image, { x: 0, y: 0, width: pageW, height: pageH });
}
}
return doc;
}

108
utils/tiff-client.ts Normal file
View file

@ -0,0 +1,108 @@
// N3 — decode TIFF ฝั่ง client: ทุกเฟรม (IFD) → RGBA → canvas → PNG blob URL ต่อเฟรม
// (TIFF หลายเฟรม = หลายหน้าเหมือน PDF หลายหน้า)
// utif2 ถูก dynamic import เฉพาะไฟล์ tif เท่านั้น (pattern invariant #6)
// ขนาดหน้า normalize กว้าง imagePageWidth (scale contract เดียวกับ jpg/png และ export)
// caller ต้อง revoke ทุก src ที่ไม่ null ตอน cleanup กัน memory leak (rules/pdf-viewer.md)
const MAX_FRAME_DIMENSION = 4096; // cap ด้านยาว decoded frame (สแกน 600dpi ~5000px — annotation เป็น % ไม่กระทบ)
type TiffIfd = import("utif2").IFD;
/** อ่านค่าตัวเลขแรกของ tag (คืน 0 ถ้าไม่ใช่ array ตัวเลข — เช่น tag ชนิดอื่น) */
function tagNumber(ifd: TiffIfd, tag: "t256" | "t257" | "t274"): number {
const value = ifd[tag];
return Array.isArray(value) ? Number(value[0]) || 0 : 0;
}
export type DecodedTiffPage = {
/** PNG blob URL ของเฟรม (null = เฟรมนี้ decode ไม่ได้ → placeholder เทา แต่หน้ายังมีขนาดจาก header) */
src: string | null;
width: number;
height: number;
};
/** TIFF orientation tag (274) — ครบทุกค่า 1–8 ตามมาตรฐาน EXIF (หมุน + mirror)
* โดย transform matrix มาตรฐาน (canvas ปลายทางสลับกว้าง-สูงสำหรับ 5–8)
* ต้องตรงกับ rotateRgba ของ utils/image-to-pdf.ts ฝั่ง export เสมอ */
function rgbaToCanvas(rgba: Uint8Array, w: number, h: number, orientation: number): HTMLCanvasElement {
const swap = orientation >= 5 && orientation <= 8; // หมุน 90° → สลับกว้าง-สูง
const targetW = swap ? h : w;
const targetH = swap ? w : h;
const scale = Math.min(1, MAX_FRAME_DIMENSION / Math.max(targetW, targetH));
// วาด RGBA ลง canvas ต้นทางก่อน แล้วค่อย transform (orientation + downscale) ลง canvas จริง
const src = document.createElement("canvas");
src.width = w;
src.height = h;
src.getContext("2d")!.putImageData(new ImageData(new Uint8ClampedArray(rgba), w, h), 0, 0);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(targetW * scale));
canvas.height = Math.max(1, Math.round(targetH * scale));
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingQuality = "high";
ctx.scale(scale, scale);
switch (orientation) {
case 2:
ctx.transform(-1, 0, 0, 1, w, 0);
break;
case 3:
ctx.transform(-1, 0, 0, -1, w, h);
break;
case 4:
ctx.transform(1, 0, 0, -1, 0, h);
break;
case 5:
ctx.transform(0, 1, 1, 0, 0, 0);
break;
case 6:
ctx.transform(0, 1, -1, 0, h, 0);
break;
case 7:
// anti-transpose: (x,y) → (h-1-y, w-1-x) — e/f เป็น h,w ตาม rotateRgba ฝั่ง export
ctx.transform(0, -1, -1, 0, h, w);
break;
case 8:
ctx.transform(0, -1, 1, 0, 0, w);
break;
}
ctx.drawImage(src, 0, 0);
return canvas;
}
export async function decodeTiffPages(
buffer: ArrayBuffer,
imagePageWidth: number,
): Promise<DecodedTiffPage[]> {
const UTIF = await import("utif2");
const ifds = UTIF.decode(buffer);
// เอาเฉพาะ IFD ที่เป็นเฟรมรูป (มี tag ขนาด t256/t257) — ไม่ใช่ EXIF metadata IFD
const frames = ifds.filter((ifd) => Array.isArray(ifd.t256) && Array.isArray(ifd.t257));
if (frames.length === 0) throw new Error("ไม่พบเฟรมรูปในไฟล์ TIFF");
const pages: DecodedTiffPage[] = [];
for (const ifd of frames) {
const headerW = tagNumber(ifd, "t256");
const headerH = tagNumber(ifd, "t257");
try {
UTIF.decodeImage(buffer, ifd);
const rgba = UTIF.toRGBA8(ifd);
const orientation = tagNumber(ifd, "t274") || 1;
const canvas = rgbaToCanvas(rgba, ifd.width, ifd.height, orientation);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new Error("แปลง canvas เป็น PNG ไม่ได้");
pages.push({
src: URL.createObjectURL(blob),
width: imagePageWidth,
height: (canvas.height / canvas.width) * imagePageWidth,
});
} catch (err) {
// เฟรมเดียวพัง (เช่น compression ที่ utif2 ไม่รอง) ไม่ทำให้ทั้งไฟล์พัง — placeholder เทา
// แต่ขนาดหน้ายังเอาจาก header ได้ → page count/scroll/bookmark/deep link เสถียร
console.error("decode เฟรม TIFF ไม่ได้ — แสดง placeholder", err);
const ratio = headerW > 0 && headerH > 0 ? headerH / headerW : 1.4;
pages.push({ src: null, width: imagePageWidth, height: imagePageWidth * ratio });
}
}
return pages;
}