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>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
// 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));
}
});
}