// 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 { 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((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; }