eCourt-note/utils/image-to-pdf.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 9cc38835f0
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
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

252 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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