feat: AcroForm-Fill via Claude CLI, Multi-Source, Kanagawa, Docker-Deploy

Komplettes Rework der AI-Studio-Vorlage zu einem produktiven Werkzeug fuer
deutsche AcroForm-Formulare (Reha-Antraege, Arzt-Befundberichte):

- Backend: Express spawnt headless Claude CLI ('claude -p --output-format json'
  via stdin-Pipe). Prompt enthaelt die Feldnamen als Ziel-Schema plus die
  Arbeitsregeln (Stichwortstil, feste Zeichen-Kaestchen ohne Leerzeichen,
  Vordrucke respektieren, keine geratenen Werte, nur medizinisch).
- PDF-Handling: pdfjs-dist statt pdf-lib — pdf-lib scheitert an verschluesselten
  Object-Streams in DRV-Formularen. annotationStorage + saveDocument, kein
  Flatten. Worker-Patch zur Laufzeit forciert Auto-Size und schwarze Schrift.
- Multi-Source-Upload: beliebig viele PDFs/Bilder + optional Freitext.
- Design: Kanagawa Design System (Preset aus ../kanagawa-design-system),
  Tailwind lokal gebaut statt CDN, Dark/Light-Toggle, Progress-Indicator.
- Deployment: Multi-Stage-Dockerfile, docker-compose in matrix_default-Netz,
  Claude-Credentials vom Host per Volume. PLAN.md + AGENTS.md (Alex-Schema).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kenearos 2026-04-20 22:48:32 +02:00
parent d6cab4aeb5
commit 3c669fb003
28 changed files with 6756 additions and 934 deletions

View file

@ -1,105 +1,144 @@
import { PDFDocument, PDFTextField, PDFCheckBox, StandardFonts, rgb } from 'pdf-lib';
import { ExtractedField } from '../types';
import * as pdfjs from 'pdfjs-dist';
// @ts-expect-error - ?url-Import ist Vite-spezifisch, wird zur Build-Zeit aufgelöst
import workerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import type { ExtractedField } from '../types';
// pdfjs rendert AcroForm-Text mit der im PDF definierten Default-Appearance
// (Font-Größe + Farbe). In Behördenformularen ist das oft 1011pt und blau,
// was im ausgefüllten PDF zu groß und farblich falsch aussieht.
//
// Lösung: Das pdf.worker.mjs-Script wird zur Laufzeit gezogen, zwei Stellen
// gepatcht und als Blob-URL als Worker-Src gesetzt:
// 1. fontSize → 0 ⇒ pdfjs schaltet auf Auto-Size (passt sich an Feldhöhe an)
// 2. fontColor → schwarz
// So bleibt der Rest des PDFs unverändert, nur Textwerte rendern kleiner/schwarz.
let workerPromise: Promise<void> | null = null;
function ensureWorker(): Promise<void> {
if (workerPromise) return workerPromise;
workerPromise = (async () => {
try {
const res = await fetch(workerUrl as string);
if (!res.ok) throw new Error(`worker fetch failed: ${res.status}`);
let src = await res.text();
// 1. Auto-Size erzwingen
src = src.replace(
/let\s*\{\s*fontSize\s*\}\s*=\s*this\.data\.defaultAppearanceData;/,
'let fontSize = 0; void this.data.defaultAppearanceData;'
);
// 2. Font-Farbe schwarz erzwingen (Arbeitsregel: Schrift schwarz)
src = src.replace(
/const\s*\{\s*fontName,\s*fontColor\s*\}\s*=\s*this\.data\.defaultAppearanceData;/,
'const { fontName } = this.data.defaultAppearanceData; const fontColor = new Uint8ClampedArray([0,0,0]);'
);
const blob = new Blob([src], { type: 'text/javascript' });
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob);
} catch (e) {
// Fallback: ungepatchter Worker — lieber laufen lassen als App brechen.
console.warn('[pdfService] worker patch failed, falling back', e);
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl as string;
}
})();
return workerPromise;
}
export interface PdfFieldInfo {
name: string;
type: string;
type: string; // 'Tx' | 'Btn' | 'Ch' | 'Sig'
}
export const getPdfFields = async (base64: string): Promise<PdfFieldInfo[]> => {
interface WidgetInfo {
id: string;
fieldName: string;
fieldType: string;
}
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function collectWidgets(
data: Uint8Array
): Promise<{ doc: pdfjs.PDFDocumentProxy; widgets: WidgetInfo[] }> {
await ensureWorker();
const loadingTask = pdfjs.getDocument({ data, isEvalSupported: false });
const doc = await loadingTask.promise;
const widgets: WidgetInfo[] = [];
for (let pageIdx = 1; pageIdx <= doc.numPages; pageIdx++) {
const page = await doc.getPage(pageIdx);
const anns = await page.getAnnotations();
for (const ann of anns) {
if (ann.subtype !== 'Widget') continue;
if (!ann.fieldName) continue;
widgets.push({
id: ann.id,
fieldName: ann.fieldName,
fieldType: ann.fieldType,
});
}
}
return { doc, widgets };
}
export async function getPdfFields(base64: string): Promise<PdfFieldInfo[]> {
try {
const pdfDoc = await PDFDocument.load(base64);
const form = pdfDoc.getForm();
const fields = form.getFields();
return fields.map(f => ({
name: f.getName(),
type: f.constructor.name
}));
} catch (error) {
console.warn("Failed to extract PDF fields", error);
const data = base64ToUint8Array(base64);
const { widgets } = await collectWidgets(data);
const seen = new Set<string>();
const unique: PdfFieldInfo[] = [];
for (const w of widgets) {
if (seen.has(w.fieldName)) continue;
seen.add(w.fieldName);
unique.push({ name: w.fieldName, type: w.fieldType });
}
return unique;
} catch (e) {
console.warn('[pdfService] getPdfFields failed:', e);
return [];
}
};
}
export const createFilledPdf = async (base64: string, fields: ExtractedField[], isFillable: boolean): Promise<Uint8Array> => {
const pdfDoc = await PDFDocument.load(base64);
const pages = pdfDoc.getPages();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
if (isFillable) {
try {
const form = pdfDoc.getForm();
const fieldMap: Record<string, string> = {};
fields.forEach(f => {
if (f.key) fieldMap[f.key] = f.value;
});
export async function createFilledPdf(
base64: string,
fields: ExtractedField[]
): Promise<Uint8Array> {
const data = base64ToUint8Array(base64);
const { doc, widgets } = await collectWidgets(data);
for (const [key, value] of Object.entries(fieldMap)) {
try {
const field = form.getField(key);
if (!field) continue;
if (field instanceof PDFTextField) {
field.setText(String(value));
} else if (field instanceof PDFCheckBox) {
const isChecked = String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'yes' || String(value).toLowerCase() === 'x';
if (isChecked) field.check();
else field.uncheck();
}
} catch (e) {
// Field might be read-only or tricky
}
}
} catch (e) {
console.warn("Error filling form fields", e);
}
} else {
// VISUAL OVERLAY MODE
// Iterate through fields and draw them at specific coordinates
for (const field of fields) {
// Skip if no value or no coordinates
if (!field.value || !field.coordinates) continue;
const { pageIndex, x, y } = field.coordinates;
// Safety check for page index and coordinates
if (typeof pageIndex !== 'number' || pageIndex < 0 || pageIndex >= pages.length) continue;
if (isNaN(x) || isNaN(y)) continue;
const page = pages[pageIndex];
const { width, height } = page.getSize();
// Convert 0-1000 coordinates to PDF Point coordinates
// PDF (0,0) is bottom-left.
// API (0,0) is top-left.
// x = (x / 1000) * width
// y = height - (y / 1000) * height
const pdfX = (x / 1000) * width;
const pdfY = height - (y / 1000) * height;
// Adjust slightly for font height (text is drawn from baseline)
// A small nudge down (subtract from Y) helps align with lines usually.
const adjustedY = pdfY - 4;
try {
page.drawText(field.value, {
x: pdfX,
y: adjustedY,
size: 10,
font: font,
color: rgb(0, 0, 0),
});
} catch (e) {
console.warn(`Failed to draw field ${field.label}`, e);
}
}
const byName = new Map<string, ExtractedField>();
for (const f of fields) {
if (f.key) byName.set(f.key, f);
}
return await pdfDoc.save();
};
export const fillPdf = async (base64: string, fieldValues: Record<string, string | boolean>): Promise<Uint8Array> => {
return new Uint8Array();
};
const store = doc.annotationStorage;
for (const w of widgets) {
const source = byName.get(w.fieldName);
if (!source) continue;
if (w.fieldType === 'Tx') {
store.setValue(w.id, { value: source.value ?? '' });
} else if (w.fieldType === 'Btn') {
const checked = isTruthyCheckbox(source.value);
store.setValue(w.id, { value: checked });
} else if (w.fieldType === 'Ch') {
store.setValue(w.id, { value: source.value ?? '' });
}
// Sig (Signature) wird übersprungen.
}
return await doc.saveDocument();
}
function isTruthyCheckbox(value: string): boolean {
const v = (value ?? '').trim().toLowerCase();
return v === 'x' || v === 'ja' || v === 'yes' || v === 'true' || v === '1';
}