// pdf-arbeit.js: das Lesen und Rendern von PDFs (pdfjs-dist, @napi-rs/canvas) // für Protokoll-Import, "TODOs aus Protokoll" und Plan Checker. Aus server.js // herausgelöst, damit es in einem EIGENEN Kindprozess läuft (pdfImKindprozess() // in server.js): Ein großes PDF braucht schnell 100–200 MB; läuft der Speicher // über, stirbt nur der Kindprozess, nicht der Server (Pi Zero 2, 512 MB). // // Doppelte Rolle: require() aus server.js liefert nur die Konstanten (die // Bibliotheken werden erst beim Aufruf geladen); per child_process.fork() // gestartet, nimmt es genau EINEN Auftrag über IPC entgegen, antwortet und // beendet sich — jeder Auftrag beginnt mit leerem Speicher. 'use strict'; const path = require('path'); let pdfjsLibPromise = null; function ladePdfjs(){ if(!pdfjsLibPromise) pdfjsLibPromise = import('pdfjs-dist/legacy/build/pdf.mjs'); return pdfjsLibPromise; } async function extrahierePdfText(buf){ const pdfjsLib = await ladePdfjs(); const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf) }); try { const doc = await loadingTask.promise; let text = ''; for(let i = 1; i <= doc.numPages; i++){ const page = await doc.getPage(i); const content = await page.getTextContent(); // Textfragmente stehen ohne Zeilenumbrüche in content.items — erkennePunkteAusText() // braucht aber echte Zeilen (Punkt-Marker müssen am Zeilenanfang stehen). Ein // Umbruch wird eingefügt, sobald sich die Y-Koordinate (transform[5]) ändert, // exakt wie es das vormals genutzte pdf-parse intern selbst tat. let lastY = null; content.items.forEach(item => { if(lastY !== null && item.transform[5] !== lastY) text += '\n'; else if(lastY !== null) text += ' '; text += item.str; lastY = item.transform[5]; }); text += '\n\n'; } return text; } finally { loadingTask.destroy(); } } // ladeCanvas/renderPdfAlsBildsatz: fürs "Plan Checker"-Feature weiter unten // (POST /plan-checker-pruefen, aufgerufen aus pruefePlanMitKI()) — rendert // ausschließlich die ERSTE Seite eines PDFs zu einem Bildsatz (Übersicht + // hochaufgelöste Kachel-Ausschnitte, s. renderPdfAlsBildsatz() dort), für // BEIDE Anbieter gleich (siehe Projekthistorie bei pruefePlanMitClaude() // unten — ein einzelnes, von uns oder vom Anbieter selbst erzeugtes // Gesamtbild der Seite erwies sich für feine Maßzahlen/Plankopf-Text // wiederholt als zu grob). Ein Planblatt pro Prüfung ist der Normalfall; // mehrseitige PDFs mit mehreren Planblättern sind vorerst nicht vorgesehen. let canvasLibPromise = null; function ladeCanvas(){ if(!canvasLibPromise) canvasLibPromise = import('@napi-rs/canvas'); return canvasLibPromise; } // PDFJS_STANDARD_FONTS: Pfad zu den von pdfjs-dist mitgelieferten Standard- // Schriftdaten (Foxit-Ersatzschriften für Helvetica/Times/Symbol/...), mit // abschließendem Trenner wie von pdf.js intern per String-Konkatenation // erwartet (`${standardFontDataUrl}${dateiname}`). OHNE diesen Parameter // wirft pdf.js beim Rendern pro fehlender Schriftart die Warnung // "UnknownErrorException: Ensure that the standardFontDataUrl API parameter // is provided" und kann Zeichen nicht auf Glyphen abbilden — ein einfacher // Testtext (reine Helvetica-Buchstaben) rendert trotzdem noch halbwegs über // einen groben Ersatz, aber bei echten CAD-/Architektur-Export-PDFs // (Revit/AutoCAD, oft mit eigens eingebetteten oder nicht eingebetteten // Spezialschriften für Maßzahlen und Plankopf-Text) blieben genau diese // Textstellen dadurch leer oder unleserlich — die Maßketten-LINIEN kamen an // (reine Vektorgrafik, schriftunabhängig), die Zahlen und der Plankopf-Text // nicht. Nur für renderPdfAlsBildsatz() relevant: extrahierePdfText() // liest ausschließlich getTextContent() (die im PDF gespeicherten // Zeichencodes), ohne je eine Glyphe zu rendern, und ist von fehlenden // Schriftdaten nicht betroffen. const PDFJS_STANDARD_FONTS = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep; // PDFJS_WASM: Pfad zu den von pdfjs-dist mitgelieferten WASM-Modulen // (u. a. jbig2.wasm, openjpeg.wasm), im selben Muster wie PDFJS_STANDARD_FONTS // oben. OHNE diesen Parameter wirft pdf.js beim Laden eines WASM-Decoders // "UnknownErrorException: Ensure that the wasmUrl API parameter is provided" // und versucht dann, JBIG2-kodierte Bildinhalte (verbreitetes Kompressions- // format für eingebettete Scan-/Rasterbilder, z. B. ein eingescanntes Logo im // Plankopf) über einen Fallback-Pfad zu laden, der mangels `wasmUrl` zu einem // kaputten Modulnamen zusammengesetzt wird ("nulljbig2_nowasm_fallback.js") // und mit ERR_MODULE_NOT_FOUND scheitert — der betroffene Bildinhalt fehlt // dadurch im gerenderten PNG, genau wie zuvor die Ebenen/Schriften. const PDFJS_WASM = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'wasm') + path.sep; // PLAN_CHECK_KACHEL_*: Rastergröße und Überlappung für renderPdfAlsBildsatz() // unten. 2x2 (vier Kacheln) mit 12% Überlappung je Rand - genug // Sicherheitsrand, falls ein Feld (z. B. der Plankopf) genau auf einer // Kachelgrenze liegt, ohne die Zahl der pro Prüfung gesendeten Bilder // (= Kosten/Dauer je Anbieter-Anfrage) unnötig in die Höhe zu treiben. const PLAN_CHECK_KACHEL_SPALTEN = 2; const PLAN_CHECK_KACHEL_ZEILEN = 2; const PLAN_CHECK_KACHEL_UEBERLAPPUNG = 0.12; const PLAN_CHECK_UEBERSICHT_BREITE = 2000; const PLAN_CHECK_KACHEL_BREITE = 2200; // renderPdfAlsBildsatz: löst die vorherige renderPdfErstePageAlsPng() ab, die // die ganze Planseite als EIN einzelnes PNG rendert hat. Grund für die // Umstellung: Nach den vorherigen Fixes (Ebenen/Schrift/WASM, s.o.) meldete // Erik einen neuen, grundsätzlicheren Befund - der Plankopf wurde zwar jetzt // gefunden und der Plantitel korrekt gelesen, aber Projektname, // Planersteller, Maßstab, Datum und Plannummer blieben "in der vorliegenden // Auflösung" nicht sämtlich lesbar. Das bestätigt in letzter Konsequenz den // Verdacht, der schon zur vorherigen PDF-Direktübergabe an Claude geführt // hatte (s. historischer Kommentar bei pruefePlanMitClaude() weiter unten): // JEDER Anbieter skaliert ein übermitteltes Bild bzw. eine PDF-Seite intern // auf eine eigene Obergrenze herunter, BEVOR das Modell draufschaut (bei // Claude laut Dokumentation ca. 1568px lange Kante, bei OpenAI vergleichbar). // Ein Plankopf ist nur ein kleiner Ausschnitt eines großen Planblatts - seine // Feinschrift liegt nach dieser anbieterseitigen Herunterskalierung weit // unter Lesbarkeit, UNABHÄNGIG davon, wie hoch WIR die Ausgangsauflösung // wählen (mehr Pixel in der Quelle ändern nichts, wenn der Anbieter ohnehin // wieder auf dieselbe Obergrenze herunterrechnet). Claudes PDF-Direktübergabe // half laut Erik auch nicht durchgreifend genug - vermutlich, weil Anthropics // eigene Dokumenten-Verarbeitung intern denselben Weg geht (PDF-Seite zu // einem einzigen Bild, dann dieselbe Auflösungsgrenze). // // Der einzige verlässliche Ausweg: der KI ZUSÄTZLICH zur Übersicht mehrere, // überlappende Ausschnitte der Seite in höherer Pixel-Dichte mitgeben, sodass // Feinschrift gleich welcher Lage auf dem Blatt in mindestens einem Bild // unterhalb der anbieterseitigen Herunterskalierungs-Schwelle bleibt - OHNE // dafür die Lage des Plankopfs erkennen zu müssen (die variiert je // Büro-Vorlage: beim getesteten Plan oben rechts, DIN-Konvention wäre unten // rechts). Jede Kachel wird NICHT aus einem fertigen Raster-Bild // zugeschnitten (das brächte keine zusätzliche Schärfe, nur eine vergrößerte // Unschärfe), sondern pdf.js erneut mit einer höheren scale-Vorgabe gerendert // und per Context-Transform (ctx.translate) auf den gewünschten Ausschnitt // verschoben - der Ausschnitt entsteht also selbst aus dem Vektor-Original in // höherer Auflösung, nicht durch Hochskalieren eines bereits gerenderten // Bilds. Gilt jetzt für BEIDE Anbieter (Eriks ausdrückliche Entscheidung): // Claude bekommt dieselben gerenderten Bilder wie ChatGPT statt wieder der // rohen PDF, damit für beide dasselbe, selbst kontrollierte Verfahren gilt // statt für Claude erneut auf unbekanntes anbieterinternes Verhalten zu // vertrauen. // // Rückgabe: { uebersicht: Buffer, kacheln: Buffer[] } - je ein PNG-Buffer. async function renderPdfAlsBildsatz(buf){ const pdfjsLib = await ladePdfjs(); const { createCanvas } = await ladeCanvas(); const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf), standardFontDataUrl: PDFJS_STANDARD_FONTS, wasmUrl: PDFJS_WASM }); try { const doc = await loadingTask.promise; const page = await doc.getPage(1); const basisViewport = page.getViewport({ scale: 1 }); // Alle optionalen Ebenen (PDF Optional Content Groups) erzwungen // sichtbar schalten, BEVOR gerendert wird: CAD-/BIM-Exporte (AutoCAD, // Revit, ...) legen technische Pläne sehr häufig in Ebenen an (z. B. // "Architektur-Grundriss", "ELT-Planung", "Legende", "Plankopf") und // markieren einen Teil davon im PDF selbst als standardmäßig AUS - oft // bewusst so exportiert, damit jedes Gewerk seine eigene Ebene in // Acrobat selbst einschaltet. Ohne diese Ergänzung rendert pdf.js exakt // diesen Vorgabe-Zustand nach: bei einem echten Plan blieben dadurch nur // der Architektur-Grundriss übrig, ELT-Planung, Legende UND Plankopf // fehlten im gerenderten Bild komplett (von Erik direkt bestätigt, nach // Ansehen des gespeicherten Debug-PNGs) - die KI konnte diese Merkmale // also gar nicht als "nicht vorhanden" FALSCH einstufen, sie bekam sie // schlicht nie zu sehen. const ebenen = await doc.getOptionalContentConfig(); for(const [ebenenId] of ebenen){ ebenen.setVisibility(ebenenId, true); } // ausschnitt (optional): {x, y, breite, hoehe} in Geräte-Pixeln DIESES // scale-Werts. Ohne ausschnitt wird die ganze Seite gerendert. async function renderAusschnitt(scale, ausschnitt){ const viewport = page.getViewport({ scale }); const breite = ausschnitt ? Math.round(ausschnitt.breite) : Math.round(viewport.width); const hoehe = ausschnitt ? Math.round(ausschnitt.hoehe) : Math.round(viewport.height); const canvas = createCanvas(breite, hoehe); const ctx = canvas.getContext('2d'); if(ausschnitt) ctx.translate(-ausschnitt.x, -ausschnitt.y); await page.render({ canvasContext: ctx, viewport, optionalContentConfigPromise: Promise.resolve(ebenen) }).promise; return canvas.toBuffer('image/png'); } // Übersicht: die ganze Seite, moderate Auflösung - liefert der KI den // Gesamtkontext (Art/Lage der Zeichnung), aber keine verlässliche // Feinschrift. const uebersicht = await renderAusschnitt(PLAN_CHECK_UEBERSICHT_BREITE / basisViewport.width, null); // Kacheln: Raster mit Überlappung, JEDE Kachel mit eigener, hoher // Skalierung gerendert (nicht aus der Übersicht zugeschnitten, s. o.). const anteilBreite = (1 / PLAN_CHECK_KACHEL_SPALTEN) + PLAN_CHECK_KACHEL_UEBERLAPPUNG; const kachelScale = PLAN_CHECK_KACHEL_BREITE / (basisViewport.width * anteilBreite); const kachelViewport = page.getViewport({ scale: kachelScale }); const kacheln = []; for(let zeile = 0; zeile < PLAN_CHECK_KACHEL_ZEILEN; zeile++){ for(let spalte = 0; spalte < PLAN_CHECK_KACHEL_SPALTEN; spalte++){ const startAnteilX = Math.max(0, spalte / PLAN_CHECK_KACHEL_SPALTEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); const startAnteilY = Math.max(0, zeile / PLAN_CHECK_KACHEL_ZEILEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); const endAnteilX = Math.min(1, (spalte + 1) / PLAN_CHECK_KACHEL_SPALTEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); const endAnteilY = Math.min(1, (zeile + 1) / PLAN_CHECK_KACHEL_ZEILEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); kacheln.push(await renderAusschnitt(kachelScale, { x: startAnteilX * kachelViewport.width, y: startAnteilY * kachelViewport.height, breite: (endAnteilX - startAnteilX) * kachelViewport.width, hoehe: (endAnteilY - startAnteilY) * kachelViewport.height })); } } return { uebersicht, kacheln }; } finally { loadingTask.destroy(); } } module.exports = { PLAN_CHECK_KACHEL_SPALTEN, PLAN_CHECK_KACHEL_ZEILEN, extrahierePdfText, renderPdfAlsBildsatz }; // ---------- Kindprozess ---------- // Speicher-Wächter im Kind: Native Speicher (Canvas) begrenzt // --max-old-space-size nicht; über PDF_KIND_MAX_MB beendet sich das Kind // selbst, der Server meldet dann "PDF zu groß". if(require.main === module && process.send){ const MAX_MB = Number(process.env.PDF_KIND_MAX_MB) || 260; setInterval(() => { if(process.memoryUsage().rss > MAX_MB * 1024 * 1024) process.exit(99); }, 250).unref(); process.once('message', async msg => { try { const buf = Buffer.from(msg.daten); const ergebnis = msg.art === 'text' ? await extrahierePdfText(buf) : await renderPdfAlsBildsatz(buf); process.send({ ok: true, ergebnis }, () => process.exit(0)); } catch(err){ process.send({ ok: false, fehler: String(err && err.message || err) }, () => process.exit(0)); } }); }