/* ===================================================================== PDF-Text herausholen -- ohne fremde Bibliothek. Gebaut am 06.10.2026, weil auf diesem Rechner weder poppler noch eine PDF-Bibliothek liegt und ein Bauplan gelesen werden musste. WIE ES ARBEITET: Alle Stroeme entpacken, die Seiten in ihrer Reihenfolge einsammeln, und den Text ueber die ToUnicode-Tabellen der jeweiligen Schrift zurueckuebersetzen. Ohne diese Tabellen kaeme Buchstabensalat heraus -- die Schriften sind Teilmengen, in denen <54> irgendein Zeichen bedeuten kann. AUFRUF: node tools/pdf-text.mjs [ziel.txt] ===================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; import { inflateSync } from "node:zlib"; const quelle = process.argv[2]; const ziel = process.argv[3] || null; if (!quelle) { console.error("Aufruf: node tools/pdf-text.mjs [ziel.txt]"); process.exit(2); } const roh = readFileSync(quelle); const text = roh.toString("latin1"); /* ---- 1. Alle Objekte einsammeln ------------------------------------ */ const objekte = new Map(); // nummer -> { dict, daten } { const re = /(\d+)\s+(\d+)\s+obj\b/g; let m; while ((m = re.exec(text))) { const nr = Number(m[1]); const ab = m.index + m[0].length; const bis = text.indexOf("endobj", ab); if (bis < 0) continue; const koerper = text.slice(ab, bis); let daten = null; const sm = /(^|[^d])stream\r?\n/.exec(koerper); if (sm) { const sAb = ab + sm.index + sm[0].length; const sBis = text.indexOf("endstream", sAb); if (sBis > 0) { try { daten = inflateSync(roh.subarray(sAb, sBis)); } catch { daten = roh.subarray(sAb, sBis); } } } objekte.set(nr, { dict: sm ? koerper.slice(0, sm.index) : koerper, daten }); } } /* ---- 2. ToUnicode-Tabellen ----------------------------------------- */ const tabellen = new Map(); // objektnummer -> Map(code -> zeichen) function tabelleLesen(nr) { if (tabellen.has(nr)) return tabellen.get(nr); const o = objekte.get(nr); const karte = new Map(); tabellen.set(nr, karte); if (!o?.daten) return karte; const t = o.daten.toString("latin1"); /* Einzelzuordnungen: <01> <0041> */ for (const block of t.match(/beginbfchar([\s\S]*?)endbfchar/g) || []) { const re = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g; let m; while ((m = re.exec(block))) karte.set(parseInt(m[1], 16), ausHex(m[2])); } /* Bereiche: <01> <05> <0041> oder <01> <03> [<0041> <0042> <0043>] */ for (const block of t.match(/beginbfrange([\s\S]*?)endbfrange/g) || []) { const re = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*(?:<([0-9A-Fa-f]+)>|\[([\s\S]*?)\])/g; let m; while ((m = re.exec(block))) { const von = parseInt(m[1], 16), bis = parseInt(m[2], 16); if (m[3]) { const basis = parseInt(m[3], 16); for (let c = von; c <= bis && c - von < 65536; c++) { karte.set(c, String.fromCodePoint(basis + (c - von))); } } else if (m[4]) { const liste = m[4].match(/<([0-9A-Fa-f]+)>/g) || []; liste.forEach((h, i) => karte.set(von + i, ausHex(h.slice(1, -1)))); } } } return karte; } function ausHex(h) { let s = ""; for (let i = 0; i + 3 < h.length + 1; i += 4) { const c = parseInt(h.slice(i, i + 4), 16); if (!Number.isNaN(c)) s += String.fromCharCode(c); } return s; } /* ---- 3. Seiten in der richtigen Reihenfolge ------------------------ */ const seiten = []; for (const [nr, o] of objekte) { if (!/\/Type\s*\/Page[^s]/.test(o.dict)) continue; seiten.push({ nr, dict: o.dict }); } seiten.sort((a, b) => a.nr - b.nr); /* ---- 4. Text je Seite ---------------------------------------------- */ const alles = []; for (const [i, seite] of seiten.entries()) { /* Welche Schrift heisst wie? /F5 12 0 R */ const schriften = new Map(); const fm = /\/Font\s*<<([\s\S]*?)>>/.exec(seite.dict); if (fm) { const re = /\/(\w+)\s+(\d+)\s+\d+\s+R/g; let m; while ((m = re.exec(fm[1]))) { const fo = objekte.get(Number(m[2])); const tu = fo && /\/ToUnicode\s+(\d+)\s+\d+\s+R/.exec(fo.dict); schriften.set("/" + m[1], tu ? tabelleLesen(Number(tu[1])) : new Map()); } } /* Inhalt -- kann ein Verweis oder eine Liste sein. */ const cm = /\/Contents\s+(?:(\d+)\s+\d+\s+R|\[([^\]]*)\])/.exec(seite.dict); let inhalt = ""; if (cm?.[1]) inhalt = objekte.get(Number(cm[1]))?.daten?.toString("latin1") || ""; else if (cm?.[2]) { for (const r of cm[2].match(/(\d+)\s+\d+\s+R/g) || []) { inhalt += (objekte.get(Number(r))?.daten?.toString("latin1") || "") + "\n"; } } let karte = new Map(); let zeile = ""; const raus = []; /* Jede Textanweisung der Reihe nach. `Td`/`TD`/`Tm`/`T*` mit Zeilenvorschub trennt Zeilen -- sonst klebt die ganze Seite zusammen und kein Mensch liest es. */ const re = /\/(\w+)\s+[\d.]+\s+Tf|<([0-9A-Fa-f\s]*)>\s*Tj|\[([\s\S]*?)\]\s*TJ|([-\d.]+)\s+([-\d.]+)\s+Td|T\*|ET/g; let m; let letztesY = null; while ((m = re.exec(inhalt))) { if (m[1]) { karte = schriften.get("/" + m[1]) || new Map(); continue; } if (m[2] !== undefined) { zeile += entziffern(m[2], karte); continue; } if (m[3] !== undefined) { for (const st of m[3].match(/<([0-9A-Fa-f\s]*)>/g) || []) { zeile += entziffern(st.slice(1, -1), karte); } /* Grosse Rueckschritte im TJ sind Wortabstaende. */ continue; } if (m[4] !== undefined) { const y = Number(m[5]); if (letztesY !== null && Math.abs(y) > 0.1) { raus.push(zeile); zeile = ""; } letztesY = y; continue; } /* T* oder ET */ if (zeile.trim()) { raus.push(zeile); zeile = ""; } } if (zeile.trim()) raus.push(zeile); alles.push(`\n═══ Seite ${i + 1} ═══\n` + raus.join("\n")); } function entziffern(hex, karte) { const h = hex.replace(/\s+/g, ""); let s = ""; for (let i = 0; i + 1 < h.length + 1; i += 2) { const c = parseInt(h.slice(i, i + 2), 16); if (Number.isNaN(c)) continue; s += karte.get(c) ?? ""; } return s; } const fertig = alles.join("\n"); if (ziel) { writeFileSync(ziel, fertig, "utf8"); console.log(`${seiten.length} Seiten -> ${ziel} (${fertig.length} Zeichen)`); } else console.log(fertig);