Files
projektuebersicht/pdf-arbeit.js
T
erikandClaude Opus 5.5 b7e0bd0d92 Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb
- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:52 +01:00

240 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}
});
}