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 { const runsByFont = new Map(); 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 Tf (ชื่อ → ref ใน Resources ของหน้า) const fontByName = new Map(); 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) { // 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 { const entries = new Map(); 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 { 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>(); 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(); 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 อะไรก็ตามที่พัง ใช้ไฟล์เดิมเลย } }