eCourt-note/utils/tiff-client.ts

109 lines
5.3 KiB
TypeScript
Raw Normal View History

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