eCourt-note/utils/image-to-pdf.ts

253 lines
13 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 — สร้าง 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;
}