- โหลด 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>
60 lines
3.2 KiB
TypeScript
60 lines
3.2 KiB
TypeScript
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);
|
|
});
|
|
});
|