popup ซ้อนกัน/ไม่ปิดตอนกดปุ่ม tool + แท็บที่คั่นหน้ากลับไม่ได้ + งานค้างใน working tree: ไฟล์รูปภาพ annotate ได้ (N3) + อัปโหลดไฟล์ (N4):
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
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
ส่วนที่ 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>
This commit is contained in:
parent
0c4af09e85
commit
9cc38835f0
25 changed files with 1269 additions and 77 deletions
252
utils/image-to-pdf.ts
Normal file
252
utils/image-to-pdf.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue