// 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 () ใช้ 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 { 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; }