ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว
- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
289d735bf3
commit
6fd3a1515f
19 changed files with 890 additions and 53 deletions
60
tests/lib/pdf-actual-text.test.ts
Normal file
60
tests/lib/pdf-actual-text.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { PDFDocument, PDFDict, PDFName, PDFRawStream, StandardFonts, decodePDFRawStream } from "pdf-lib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { collectActualTextRuns, fixPdfActualText } from "@/lib/pdf-actual-text";
|
||||
|
||||
// ไฟล์คำสั่งศาลปกครองจริง — cmap ของฟอนต์เพี้ยน (สระ/วรรณยุกต์กลายเป็น control char)
|
||||
// แต่ฝัง /ActualText ถูกไว้ใน marked content
|
||||
const SAMPLE = readFileSync(
|
||||
join(process.cwd(), "public", "samples", "court_command_11_123 1.pdf"),
|
||||
);
|
||||
|
||||
describe("collectActualTextRuns — เรียนรู้ glyph จาก ActualText ของไฟล์จริง", () => {
|
||||
it("เจอรันหลักพัน + มีอย่างน้อย 1 ฟอนต์", async () => {
|
||||
const pdfDoc = await PDFDocument.load(SAMPLE, { ignoreEncryption: true });
|
||||
const runs = collectActualTextRuns(pdfDoc);
|
||||
const total = Array.from(runs.values()).reduce((n, list) => n + list.length, 0);
|
||||
expect(runs.size).toBeGreaterThanOrEqual(1);
|
||||
expect(total).toBeGreaterThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fixPdfActualText — ซ่อม ToUnicode บนสำเนาในหน่วยความจำ", () => {
|
||||
it("ไฟล์ cmap เพี้ยน: ได้ bytes ใหม่ ที่ CMap มีสระ/วรรณยุกต์ถูกต้อง", async () => {
|
||||
const out = await fixPdfActualText(new Uint8Array(SAMPLE));
|
||||
expect(Buffer.from(out).equals(SAMPLE)).toBe(false); // มีการแก้จริง
|
||||
|
||||
// โหลดซ้ำ → หา ToUnicode ของทุกฟอนต์ → ต้องมี ู (U+0E39) และ ี (U+0E35) โผล่
|
||||
const doc = await PDFDocument.load(out, { ignoreEncryption: true });
|
||||
let cmapAll = "";
|
||||
for (const page of doc.getPages()) {
|
||||
const fontRes = page.node.Resources()?.lookup(PDFName.of("Font"));
|
||||
if (!(fontRes instanceof PDFDict)) continue;
|
||||
for (const [, ref] of Array.from(fontRes.entries())) {
|
||||
const font = doc.context.lookup(ref);
|
||||
if (!(font instanceof PDFDict)) continue;
|
||||
const toUni = font.get(PDFName.of("ToUnicode"));
|
||||
if (!toUni) continue;
|
||||
const stream = doc.context.lookup(toUni);
|
||||
if (stream instanceof PDFRawStream) {
|
||||
cmapAll += Buffer.from(decodePDFRawStream(stream).decode()).toString("latin1");
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(cmapAll).toMatch(/<0E39>/); // ู
|
||||
expect(cmapAll).toMatch(/<0E35>/); // ี
|
||||
expect(cmapAll).not.toMatch(/<FEFF>/); // ไม่ใส่ BOM ในแต่ละ entry
|
||||
});
|
||||
|
||||
it("PDF ปกติไม่มี ActualText: คืน bytes เดิมทั้งก้อน (ไม่แตะ)", async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
doc.addPage().drawText("hello", { font, size: 12 });
|
||||
const bytes = await doc.save();
|
||||
const out = await fixPdfActualText(bytes);
|
||||
expect(Buffer.from(out).equals(Buffer.from(bytes))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest";
|
|||
import type { AnnotationDTO, AnnotationData, AnnotationType } from "@/db/schema";
|
||||
import {
|
||||
DRAW_PENS,
|
||||
STAMP_FONT_RATIO,
|
||||
STAMP_PRESETS,
|
||||
STAMP_TEXT_WIDTH_RATIO,
|
||||
annotationNote,
|
||||
annotationPreviewText,
|
||||
annotationTypeLabel,
|
||||
effectiveDrawOpacity,
|
||||
sanitizePdfText,
|
||||
stampFitFontSize,
|
||||
} from "@/utils/annotation-display";
|
||||
|
||||
const dto = (type: AnnotationType, data: AnnotationData): AnnotationDTO => ({
|
||||
|
|
@ -107,3 +111,65 @@ describe("presets ที่ sidebar/export ใช้ร่วมกัน", () =
|
|||
expect(STAMP_PRESETS).toEqual(["ตรวจสอบแล้ว", "สำคัญ"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampFitFontSize — ย่อฟอนต์ตรายางให้ label บรรทัดเดียว", () => {
|
||||
// measure จำลอง: อักษรฐาน ๆ กว้าง 0.55em (ไม่มี document บนจอ = คืน 0 ก็ผ่านเงื่อนไขพอดี)
|
||||
const measure = (text: string, fontSize: number) =>
|
||||
Array.from(text).filter((ch) => !/[ัิ-ฺ็-๎]/.test(ch)).length *
|
||||
0.55 *
|
||||
fontSize;
|
||||
|
||||
it("ข้อความสั้น (สำคัญ) พอดี → คงขนาด base = STAMP_FONT_RATIO × กรอบ", () => {
|
||||
const w = 144;
|
||||
expect(stampFitFontSize("สำคัญ", w, measure)).toBe(w * STAMP_FONT_RATIO);
|
||||
});
|
||||
|
||||
it("ข้อความยาว (ตรวจสอบแล้ว) ล้นกรอบ → ย่อตามสัดส่วนที่ล้น จนกว้าง = STAMP_TEXT_WIDTH_RATIO × กรอบ", () => {
|
||||
const w = 144;
|
||||
const size = stampFitFontSize("ตรวจสอบแล้ว", w, measure);
|
||||
expect(size).toBeLessThan(w * STAMP_FONT_RATIO);
|
||||
expect(measure("ตรวจสอบแล้ว", size)).toBeCloseTo(w * STAMP_TEXT_WIDTH_RATIO);
|
||||
});
|
||||
|
||||
it("ข้อความว่าง/วัดไม่ได้ (คืน 0) → ใช้ขนาด base", () => {
|
||||
expect(stampFitFontSize("", 100, measure)).toBe(100 * STAMP_FONT_RATIO);
|
||||
expect(stampFitFontSize("ตรวจสอบแล้ว", 100, () => 0)).toBe(100 * STAMP_FONT_RATIO);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("sanitizePdfText — ล้างข้อความเพี้ยนจาก textLayer ของ PDF ไทย (glyph ต่อ glyph)", () => {
|
||||
// control char ขยะที่พบจริงใน textLayer ของคำสั่งศาลปกครอง เช่น 04/05/0e/0f/03/12/1b
|
||||
const ctrl = (n: number) => String.fromCharCode(n);
|
||||
|
||||
it("กลุ่ม control ขยะที่มีช่องว่างล้อมสองข้าง = ตัดทั้งกลุ่ม (เกิดในคำเดียวกัน)", () => {
|
||||
// "คำ"+"สั่"+" "+04+" "+05+" "+"งศาลปกครอง" จาก textLayer จริง
|
||||
expect(sanitizePdfText(`คำสั่ ${ctrl(4)} ${ctrl(5)} งศาลปกครอง`)).toBe("คำสั่งศาลปกครอง");
|
||||
expect(sanitizePdfText(`สั่ ${ctrl(14)} งสั่ ${ctrl(15)} ด`)).toBe("สั่งสั่ด");
|
||||
});
|
||||
|
||||
it("control ขยะที่ช่องว่างติดข้างเดียว = เก็บช่องว่างไว้ (อาจเป็นช่องว่างจริง)", () => {
|
||||
// "ที่"+12+05+" "+"๑๑" — ช่องว่างก่อน ๑๑ เป็นช่องว่างจริงของเอกสาร
|
||||
expect(sanitizePdfText(`ที่${ctrl(18)}${ctrl(5)} ๑๑`)).toBe("ที่ ๑๑");
|
||||
// control เปล่า ๆ ไม่มีช่องว่างแนบ = ตัดทิ้งอย่างเดียว
|
||||
expect(sanitizePdfText(`คำสั่${ctrl(4)}${ctrl(5)}ง`)).toBe("คำสั่ง");
|
||||
});
|
||||
|
||||
it("ยุบช่องว่างซ้ำและเก็บตำแหน่งขึ้นบรรทัดใหม่ (\\n ไม่โดนตัด)", () => {
|
||||
expect(sanitizePdfText("กับลิ้ง ปุจฉา")).toBe("กับลิ้งปุจฉา");
|
||||
expect(sanitizePdfText("บรรทัดหนึ่ง \n บรรทัดสอง")).toBe("บรรทัดหนึ่ง\nบรรทัดสอง");
|
||||
});
|
||||
|
||||
it("heading จริงของคำสั่งศาลปกครอง (space ขยะเป็นชุด + ctrl กลางคำ) → ตัดหมด", () => {
|
||||
// "คำ"+" "×3+"สั่"+04+05+"งศาลปกครอง"+" "×2+"สั่"+0e+"ง"+" "×2+"สั่"+0f+"ด"
|
||||
const heading =
|
||||
`คำ สั่${ctrl(4)}${ctrl(5)}งศาลปกครอง สั่${ctrl(14)}ง สั่${ctrl(15)}ด`;
|
||||
expect(sanitizePdfText(heading)).toBe("คำสั่งศาลปกครองสั่งสั่ด");
|
||||
});
|
||||
|
||||
it("ข้อความปกติไม่มีขยะ = คงเดิม", () => {
|
||||
expect(sanitizePdfText("คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖")).toBe(
|
||||
"คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@ describe("mergeRectsByLine", () => {
|
|||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("บรรทัดติดกันที่กล่องซ้อนกันเล็กน้อย (leading แน่น/ฟอนต์ไทย) → ยังแยกบรรทัดกัน", () => {
|
||||
// ซ้อน 1 หน่วยจากความสูง 5 (20%) — ไม่ใช่บรรทัดเดียวกัน
|
||||
const out = mergeRectsByLine([
|
||||
{ x: 10, y: 20, width: 30, height: 5 },
|
||||
{ x: 12, y: 24, width: 30, height: 5 },
|
||||
]);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ลำดับ input ไม่สำคัญ — rect ซ้ายมาทีหลังก็รวมถูกจุดเริ่ม", () => {
|
||||
const out = mergeRectsByLine([
|
||||
{ x: 45, y: 21, width: 20, height: 5 },
|
||||
|
|
|
|||
|
|
@ -244,6 +244,29 @@ describe("scaleAnnotationData", () => {
|
|||
expect(out).toEqual({ position: { x: 50, y: 10 }, message: "m" });
|
||||
});
|
||||
|
||||
it("STAMP anchor มุมตรงข้าม ×2: position ขยายจาก anchor และ scale ×2", () => {
|
||||
// bbox เดิม x 10 y 20 กว้าง 18 สูง 7.2 → anchor = มุมขวาล่าง (28, 27.2)
|
||||
const out = scaleAnnotationData(
|
||||
{ label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 },
|
||||
{ x: 28, y: 27.2 },
|
||||
2,
|
||||
2,
|
||||
);
|
||||
expect(out).toEqual({ label: "สำคัญ", position: { x: -8, y: 12.8 }, scale: 36 });
|
||||
});
|
||||
|
||||
it("STAMP หลังขยาย ×k รอบ anchor: bbox ใหม่ = ขอบเดิม ×k รอบ anchor (กว้าง-สูงผูกกัน)", () => {
|
||||
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 };
|
||||
const anchor = { x: 28, y: 27.2 };
|
||||
const out = scaleAnnotationData(data, anchor, 1.5, 1.5);
|
||||
const before = annotationBBox({ type: "STAMP", data }, S);
|
||||
const after = annotationBBox({ type: "STAMP", data: out }, S);
|
||||
expect(after.width).toBeCloseTo(before.width * 1.5);
|
||||
expect(after.height).toBeCloseTo(before.height * 1.5); // สัดส่วน 0.4 คงเดิม
|
||||
expect(after.x + after.width).toBeCloseTo(anchor.x); // ขอบที่ anchor อยู่นิ่ง
|
||||
expect(after.y + after.height).toBeCloseTo(anchor.y);
|
||||
});
|
||||
|
||||
it("DRAW: strokeWidth คงเดิมตามสเปก F1", () => {
|
||||
const out = scaleAnnotationData(
|
||||
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue