- 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>
240 lines
13 KiB
JavaScript
240 lines
13 KiB
JavaScript
// 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));
|
||
}
|
||
});
|
||
}
|