- โหลด 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>
202 lines
9.9 KiB
TypeScript
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 อะไรก็ตามที่พัง ใช้ไฟล์เดิมเลย
|
|
}
|
|
}
|