eCourt-note/lib/pdf-actual-text.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 6fd3a1515f ซ่อม 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>
2026-09-11 13:28:35 +07:00

202 lines
9.9 KiB
TypeScript

import {
decodePDFRawStream,
PDFArray,
PDFDict,
PDFDocument,
PDFName,
PDFRawStream,
PDFRef,
PDFStream,
} from "pdf-lib";
// ซ่อมข้อความ PDF ไทยที่ cmap ของฟอนต์เพี้ยน (เช่น คำสั่งศาลปกครอง) โดยเรียนรู้
// glyph-code → ข้อความจาก /ActualText ใน marked content ของ content stream
// (ตัวสร้างไฟล์ฝังข้อความถูกไว้ตรงนั้น แต่ ToUnicode CMap เพี้ยนจน pdf.js สกัดได้
// control char ขยะแทนสระ/วรรณยุกต์) แล้ว**เขียน ToUnicode ใหม่บนสำเนาในหน่วยความจำ**
// — ไฟล์ต้นฉบับไม่ถูกแตะ (invariant #1) เฉพาะ buffer ที่จะส่งให้ pdf.js เท่านั้น
//
// กลไก: ActualText ครอบรันของ glyph-code แล้วให้ข้อความของรันนั้น — ไทยมีสระ/วรรณยุกต์
// เป็น glyph แยกตัวกันพอดี จึงจัดข้อความกับ code ทีละตัว 1:1 ตาม code point ได้
// (จำนวนไม่เท่า = ยกข้อความทั้งก้อนให้ code แรก) แล้วโหวตเออกันส่วนมากระหว่างรัน
// ก่อน override ลง ToUnicode เดิม (รักษา mapping ที่ถูกอยู่แล้ว เช่น พยัญชนะทั่วไป)
/** ถอดรหัส stream ของ pdf-lib (FlateDecode ฯลฯ) เป็นข้อความ latin1 สำหรับสแกน regex */
function streamToText(obj: unknown): string {
if (obj instanceof PDFRawStream) {
return Buffer.from(decodePDFRawStream(obj).decode()).toString("latin1");
}
if (obj instanceof PDFStream) {
try {
return Buffer.from(obj.getContents()).toString("latin1");
} catch {
return "";
}
}
return "";
}
/** hex UTF-16BE (มี BOM นำหน้าหรือไม่ก็ได้) → string */
function utf16beHexToStr(hex: string): string {
let s = "";
for (let i = 0; i + 1 < hex.length; i += 4) {
s += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16));
}
// ตัด BOM (U+FEFF) นำหน้า — ActualText ของ Word/exporter ใส่มาเสมอ
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
}
/** รัน glyph ใต้ ActualText หนึ่งก้อน: hex รวมของ code + ข้อความที่ถูก */
type ActualRun = { hex: string; str: string };
/** สแกน content stream ทุกหน้า → รายการรันต่อ font ("12 0 R") */
export function collectActualTextRuns(pdfDoc: PDFDocument): Map<string, ActualRun[]> {
const runsByFont = new Map<string, ActualRun[]>();
for (const page of pdfDoc.getPages()) {
const contents = page.node.Contents();
let text = "";
if (contents instanceof PDFArray) {
for (let i = 0; i < contents.size(); i++) {
text += `${streamToText(pdfDoc.context.lookup(contents.get(i)))}\n`;
}
} else if (contents) {
text = streamToText(pdfDoc.context.lookup(contents));
}
if (!text) continue;
// font ปัจจุบันตาม /Fx <size> Tf (ชื่อ → ref ใน Resources ของหน้า)
const fontByName = new Map<string, unknown>();
try {
const fontRes = page.node.Resources()?.lookup(PDFName.of("Font"));
if (fontRes instanceof PDFDict) {
for (const [k, v] of fontRes.entries()) {
fontByName.set(k.asString().replace(/^\//, ""), v);
}
}
} catch {
// หน้าไหนไม่มี Resources ที่ใช้ได้ — ข้าม (รันของหน้านั้นไม่ถูกเรียน)
}
let curFont: unknown = null;
let pendingActual: string | null = null;
const re =
/(\/[A-Za-z0-9+.]+)\s+[\d.]+\s+Tf|\/ActualText\s*(?:<([0-9A-Fa-f]*)>|\(((?:[^()\\]|\\.)*)\))|(BDC)|(EMC)|<([0-9A-Fa-f]+)>\s*Tj|\[((?:[^\]]*))\]\s*TJ/g;
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
if (m[1] !== undefined) {
curFont = fontByName.get(m[1].slice(1)) ?? null;
} else if (m[2] !== undefined || m[3] !== undefined) {
pendingActual = m[2] !== undefined ? utf16beHexToStr(m[2]) : (m[3] ?? "");
} else if (m[5] !== undefined) {
pendingActual = null; // EMC ปิด span
} else if (pendingActual !== null && curFont !== null && curFont !== undefined) {
// <glyphs>Tj หรือ [glyphs…]TJ ใต้ ActualText — รวม hex เป็นรันเดียว
const hex =
m[6] ?? Array.from((m[7] ?? "").matchAll(/<([0-9A-Fa-f]+)>/g)).map((h) => h[1]).join("");
if (hex) {
const key = String(curFont);
const list = runsByFont.get(key) ?? [];
list.push({ hex: hex.toUpperCase(), str: pendingActual });
runsByFont.set(key, list);
}
}
}
}
return runsByFont;
}
/** แตกคู่ code→ข้อความจาก bfchar/bfrange ของ CMap เดิม (เก็บ mapping ถูกไว้) */
function parseCmapEntries(cmapText: string): Map<string, string> {
const entries = new Map<string, string>();
for (const m of Array.from(cmapText.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g))) {
entries.set(m[1].toUpperCase(), utf16beHexToStr(m[2]));
}
return entries;
}
/** ความยาว hex ต่อ 1 code ของ font — จาก codespacerange ของ ToUnicode เดิม
* (fallback: Type0 = 2 byte, อื่น ๆ 1 byte) */
function codeHexLen(fontObj: PDFDict, oldCmapText: string): number {
const cs = /begincodespacerange\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/.exec(oldCmapText);
if (cs) return cs[1].length;
const subtype = fontObj.get(PDFName.of("Subtype"));
return String(subtype) === "/Type0" ? 4 : 2;
}
const toUniHex = (s: string): string => {
let hex = "";
for (const ch of s) hex += ch.charCodeAt(0).toString(16).padStart(4, "0");
return hex.toUpperCase();
};
/**
* ซ่อม ToUnicode ของทุกฟอนต์ที่เรียนจาก ActualText ได้ — คืน bytes ใหม่
* (เสมือนไฟล์เดิมแต่ข้อความถูก) หรือคืน bytes เดิมทั้งก้อนเมื่อไม่มีอะไรต้องแก้ /
* ผิดพลาด (fail-safe: หน้า viewer ต้องไม่พังเพราะ PDF แปลก ๆ)
*/
export async function fixPdfActualText(bytes: Uint8Array): Promise<Uint8Array> {
try {
const pdfDoc = await PDFDocument.load(bytes, {
ignoreEncryption: true,
updateMetadata: false,
});
const runsByFont = collectActualTextRuns(pdfDoc);
let anyChanged = false;
for (const [fontKey, runs] of Array.from(runsByFont)) {
const refMatch = /(\d+)\s+(\d+)\s+R/.exec(fontKey);
if (!refMatch) continue;
const fontObj = pdfDoc.context.lookup(
PDFRef.of(Number(refMatch[1]), Number(refMatch[2])),
);
if (!(fontObj instanceof PDFDict)) continue;
const oldToUni = fontObj.get(PDFName.of("ToUnicode"));
const oldText = oldToUni ? streamToText(pdfDoc.context.lookup(oldToUni)) : "";
const codeLen = codeHexLen(fontObj, oldText);
const merged = new Map(parseCmapEntries(oldText));
// โหวต code → ข้อความ (รันหลายก้อนอาจให้ความหมายเดียวกัน — เอาเสียงข้างมาก)
const votes = new Map<string, Map<string, number>>();
for (const { hex, str } of runs) {
if (hex.length % codeLen !== 0) continue; // รันยาวไม่ลงตัว = สแกนพลาด ข้าม
const codes = hex.match(new RegExp(`.{${codeLen}}`, "g")) ?? [];
const chars = Array.from(str);
for (let i = 0; i < codes.length; i++) {
const s = chars.length === codes.length ? chars[i] : i === 0 ? str : "";
const v = votes.get(codes[i]) ?? new Map<string, number>();
v.set(s, (v.get(s) ?? 0) + 1);
votes.set(codes[i], v);
}
}
let changed = 0;
for (const [code, v] of Array.from(votes)) {
const best = Array.from(v.entries()).sort((a, b) => b[1] - a[1])[0][0];
if (merged.get(code) !== best) {
merged.set(code, best);
changed++;
}
}
if (changed === 0) continue;
anyChanged = true;
// เขียน CMap ใหม่ (bfchar ชุดละ ≤100 ตามสเปก) — ไม่ใส่ BOM ในแต่ละ entry
const codes = Array.from(merged.keys()).sort();
let cmap =
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 1 def\n/CMapName /ActualTextFix def\n" +
`1 begincodespacerange\n<${codes[0]}><${codes[codes.length - 1]}>\nendcodespacerange\n`;
for (let i = 0; i < codes.length; i += 100) {
const chunk = codes.slice(i, i + 100);
cmap += `${chunk.length} beginbfchar\n`;
for (const c of chunk) cmap += `<${c}> <${toUniHex(merged.get(c) ?? "")}>\n`;
cmap += "endbfchar\n";
}
cmap += "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n";
fontObj.set(PDFName.of("ToUnicode"), pdfDoc.context.register(pdfDoc.context.flateStream(cmap)));
}
if (!anyChanged) return bytes;
return await pdfDoc.save();
} catch {
return bytes; // fail-safe — PDF อะไรก็ตามที่พัง ใช้ไฟล์เดิมเลย
}
}