eCourt-note/tests/utils/image-to-pdf.test.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

113 lines
4.6 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.

import { describe, expect, it } from "vitest";
import { jpegExifOrientation, rotateRgba } from "@/utils/image-to-pdf";
/** สร้าง JPEG หลอง (SOI + marker ที่กำหนด + SOI-trailer) สำหรับทดสอบ parser */
function fakeJpeg(markers: Buffer[]): Buffer {
return Buffer.concat([Buffer.from([0xff, 0xd8]), ...markers]);
}
/** APP1 Exif ที่มีแค่ tag Orientation (274) ตาม byte layout จริง */
function app1Exif(orientation: number): Buffer {
const tiff = Buffer.alloc(26);
tiff.write("II", 0, "ascii");
tiff.writeUInt16LE(42, 2);
tiff.writeUInt32LE(8, 4);
tiff.writeUInt16LE(1, 8);
tiff.writeUInt16LE(274, 10);
tiff.writeUInt16LE(3, 12);
tiff.writeUInt32LE(1, 14);
tiff.writeUInt16LE(orientation, 18);
const payload = Buffer.concat([Buffer.from("Exif\0\0", "latin1"), tiff]);
const app1 = Buffer.alloc(4 + payload.length);
app1[0] = 0xff;
app1[1] = 0xe1;
app1.writeUInt16BE(payload.length + 2, 2);
payload.copy(app1, 4);
return app1;
}
describe("jpegExifOrientation", () => {
it("อ่าน orientation จาก APP1 ได้ (ค่า 1–8)", () => {
for (const v of [1, 3, 6, 8]) {
expect(jpegExifOrientation(fakeJpeg([app1Exif(v)]))).toBe(v);
}
});
it("ไม่มี EXIF / ไม่ใช่ JPEG = 1", () => {
expect(jpegExifOrientation(fakeJpeg([]))).toBe(1);
expect(jpegExifOrientation(Buffer.from([0x89, 0x50]))).toBe(1); // PNG signature
});
it("ข้าม marker อื่นก่อนเจอ APP1 (เช่น APP0 JFIF)", () => {
const app0 = Buffer.from([0xff, 0xe0, 0x00, 0x04, 0x4a, 0x46]); // len=4
expect(jpegExifOrientation(fakeJpeg([app0, app1Exif(6)]))).toBe(6);
});
it("APP1 ที่ไม่ใช่ Exif หรือ value ผิดพิสัย = 1", () => {
const notExif = Buffer.from([0xff, 0xe1, 0x00, 0x0a, 0x58, 0x4d, 0x4c, 0, 0, 1, 2, 3]); // "XML..."
expect(jpegExifOrientation(fakeJpeg([notExif]))).toBe(1);
expect(jpegExifOrientation(fakeJpeg([app1Exif(99)]))).toBe(1);
});
it("ทนไฟล์เสี้ยว — APP1 ถูกตัดกลางคันไม่โยน exception (คืน 1)", () => {
const full = app1Exif(6);
for (const cut of [10, 14, 20, 24]) {
const truncated = full.subarray(0, cut);
expect(jpegExifOrientation(fakeJpeg([truncated]))).toBe(1);
}
});
it("ข้าม fill bytes (0xFF ซ้ำ) ก่อน marker ได้", () => {
const fills = Buffer.from([0xff, 0xff, 0xff]);
expect(jpegExifOrientation(fakeJpeg([fills, app1Exif(6)]))).toBe(6);
});
});
/** ตาราง 3×2 RGBA — pixel ระบุตำแหน่งด้วยค่าสี r=x, g=y เพื่อตรวจ mapping */
const W = 3;
const H = 2;
const rgba = new Uint8Array(W * H * 4);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
rgba[i] = x; // r = ตำแหน่ง x เดิม
rgba[i + 1] = y; // g = ตำแหน่ง y เดิม
rgba[i + 2] = 0;
rgba[i + 3] = 255;
}
}
const pixel = (data: Uint8Array, x: number, y: number, width: number) => {
const i = (y * width + x) * 4;
return [data[i], data[i + 1]];
};
describe("rotateRgba", () => {
it("orientation 1 = ไม่แปลง", () => {
const out = rotateRgba(rgba, W, H, 1);
expect(out.width).toBe(W);
expect(pixel(out.data, 2, 1, W)).toEqual([2, 1]);
});
it("6 = หมุน 90°CW: สลับกว้าง-สูง, มุมซ้ายบนเดิมไปมุมขวาบน", () => {
const out = rotateRgba(rgba, W, H, 6);
expect(out.width).toBe(H);
expect(out.height).toBe(W);
expect(pixel(out.data, H - 1, 0, H)).toEqual([0, 0]); // (0,0) → (h-1, 0)
expect(pixel(out.data, 0, W - 1, H)).toEqual([W - 1, H - 1]); // (w-1,h-1) → (0, w-1)
});
it("5 = transpose และ 7 = anti-transpose (mirror + หมุน)", () => {
const t5 = rotateRgba(rgba, W, H, 5);
expect(t5.width).toBe(H);
expect(pixel(t5.data, 1, 2, H)).toEqual([2, 1]); // (x,y) → (y,x)
const t7 = rotateRgba(rgba, W, H, 7);
expect(t7.width).toBe(H);
expect(pixel(t7.data, H - 1 - 1, W - 1 - 2, H)).toEqual([2, 1]); // (x,y) → (h-1-y, w-1-x)
});
it("2 = flip แนวนอน, 3 = 180°, 4 = flip แนวตั้ง", () => {
expect(pixel(rotateRgba(rgba, W, H, 2).data, W - 1, 0, W)).toEqual([0, 0]);
expect(pixel(rotateRgba(rgba, W, H, 3).data, W - 1, H - 1, W)).toEqual([0, 0]);
expect(pixel(rotateRgba(rgba, W, H, 4).data, 0, H - 1, W)).toEqual([0, 0]);
});
});