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