diff --git a/docs/schema-wuensche/dokumente.md b/docs/schema-wuensche/dokumente.md new file mode 100644 index 0000000..9e73847 --- /dev/null +++ b/docs/schema-wuensche/dokumente.md @@ -0,0 +1,187 @@ +# Schema-Wünsche aus Etappe 3 „Papier“ + +Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde +absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und +funktioniert, wäre aber sauberer, wenn es im Modell stünde. + +Reihenfolge: was am meisten weh tut, steht oben. + +--- + +## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen + +**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt +„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau +so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`, +Prüfung in `dokumentVerknuepfen`). + +**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein +Seed oder ein späterer Import das nicht verletzen kann: + +```sql +ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK ( + (("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int + + ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int + + ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int + + ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int + + ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int + + ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1 +); +``` + +Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das +Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung +auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr +erklärbar. + +**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen +werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei +Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte, +z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId") +WHERE "vorgangId" IS NOT NULL;`, würde das schließen. + +--- + +## 2. `Dokument.ocrStatus` ist ein freier String + +**Stand:** `ocrStatus String?` mit dem Kommentar +`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`. + +**Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in +`docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche +(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt +heute erst auf, wenn das Abzeichen leer bleibt. + +--- + +## 3. Kein Index auf `Dokument.objektSchluessel` + +**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das +Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu +bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf. + +**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also +ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`. + +--- + +## 4. Keine Dublettenerkennung über die Prüfsumme + +**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer +denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze. + +**Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload +sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den +zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt). + +--- + +## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId` + +**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim +Verknüpfen muss deshalb über die Beziehung gehen +(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`). + +**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen +Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“ +ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und +Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler +passieren. + +--- + +## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel + +**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein +OCR-Lauf zuletzt versucht wurde, steht nirgends. + +**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in +`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf +`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein +dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es +bei jedem Nachziehen erneut durch Tesseract zu schicken. + +--- + +## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien + +Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die +festgehalten gehört. Bei `Dokument.ocrText` steht: + +> „die Datei selbst liegt verschlüsselt im Objektspeicher“ + +Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts` +verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär- +Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus +`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für +`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert — +ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort +läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den +AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter. + +Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet — +sonst wäre sie als Nachweis gegenüber dem Gericht wertlos. + +Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()` +würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt, +entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber +konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen +berührt. + +--- + +# Anhang: kein Schema-Wunsch, aber dringend + +## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen + +Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**, +also auch die Berichte aus Etappe 5. + +**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit + +``` +TypeError: Cannot read properties of undefined (reading 'unitsPerEm') + at @react-pdf/textkit/lib/textkit.js:3090 +``` + +— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist +schuld: in reinem Node reproduziert es genauso. + +**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6` +festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind +inzwischen weitergewandert: + +| Paket | installiert | von renderer 4.1.6 erwartet | +|---|---|---| +| `@react-pdf/font` | 3.1.0 | `^3.0.1` | +| `@react-pdf/layout` | 4.7.1 | `^4.2.0` | +| `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) | + +`@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher +löste die Schicht darunter den bloßen Namen selbst auf. +`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit +`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen +beiden klafft eine Lücke. + +**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende +Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und +hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues +Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf, +alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test: +„Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`). + +**Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier): +`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen — + +```jsonc +"overrides": { + "@react-pdf/layout": "4.2.x", + "@react-pdf/textkit": "5.x" +} +``` + +Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ +`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen +Unterabhängigkeiten wieder zusammenpassen. Danach kann +`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()` +merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus. diff --git a/src/app/(app)/dokumente/[id]/page.tsx b/src/app/(app)/dokumente/[id]/page.tsx new file mode 100644 index 0000000..651a94b --- /dev/null +++ b/src/app/(app)/dokumente/[id]/page.tsx @@ -0,0 +1,257 @@ +import { notFound } from 'next/navigation' +import { FileCheck2, Fingerprint, ScanText } from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' +import { SeitenKopf } from '@/components/seiten-kopf' +import { + ArtAbzeichen, + OcrAbzeichen, + PosteingangAbzeichen, + RichtungAbzeichen, + TagAbzeichen, +} from '@/components/dokumente/abzeichen' +import { DateiAktionen } from '@/components/dokumente/datei-aktionen' +import { ErkennungPanel } from '@/components/dokumente/erkennung-panel' +import { MetadatenFormular } from '@/components/dokumente/metadaten-formular' +import { PosteingangStufen } from '@/components/dokumente/posteingang-stufen' +import { Verknuepfungen } from '@/components/dokumente/verknuepfungen' +import { db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { dokumentenkontext, tagsDerAkte, vorgaengeDerAkte } from '@/lib/dokumente/kontext' +import { VERKNUEPFUNG_INCLUDE, verknuepfungAnzeigen, zieleDerAkte } from '@/lib/dokumente/ziele' +import { formatBytes } from '@/lib/speicher/dateityp' +import { formatDatumZeit } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +/** + * Ein Dokument in allen Einzelheiten. + * + * Neben den Metadaten stehen hier zwei Dinge, die es sonst nirgends gibt: + * der **Ablageort des Originals in Papier** und die **SHA-256-Pruefsumme**. + * Die Pruefsumme ist der Nachweis gegenueber dem Betreuungsgericht und + * moeglichen Miterben, dass die Datei seit der Aufnahme in die Akte nicht + * veraendert wurde. + */ +export default async function DokumentSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen') + const schreibrecht = darfSchreiben('DOKUMENTE') + + const roh = await db.dokument.findFirst({ + where: { id, betreuteId, ...nichtGeloescht }, + include: { + tags: { select: { id: true, name: true } }, + verknuepfungen: { include: VERKNUEPFUNG_INCLUDE, orderBy: { erstelltAm: 'asc' } }, + }, + }) + if (!roh) notFound() + + const dokument = oeffnen('Dokument', roh) + + const [tags, ziele, vorgaenge] = await Promise.all([ + tagsDerAkte(betreuteId), + zieleDerAkte(betreuteId), + vorgaengeDerAkte(betreuteId), + ]) + + const verknuepfungen = roh.verknuepfungen + .map(verknuepfungAnzeigen) + .filter((eintrag): eintrag is NonNullable => eintrag !== null) + + const istBild = Boolean(roh.mimeTyp?.startsWith('image/')) + + return ( +
+ + + + + + {roh.tags.map((tag) => ( + + ))} +
+ } + pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: roh.titel }]} + /> + + + + Stand im Posteingang + + Wo steht dieses Schreiben gerade? Gesichtet heißt gelesen, zugeordnet heißt: es hängt an + einem Vorgang. + + + + + + + +
+ + + Angaben zum Dokument + + Was hier steht, findest du später über die Suche wieder. + + + + tag.id), + }} + /> + + + +
+ + + Die Datei + + Wird nur über eine kurzlebige, signierte Adresse ausgeliefert – jeder Abruf steht im + Protokoll. + + + + + + {roh.objektSchluessel && ( + <> + +
+
+
Dateiname
+
{roh.dateiname ?? '–'}
+
+
+
Größe und Umfang
+
+ {formatBytes(roh.groesseBytes ?? 0)} + {roh.seitenzahl ? ` · ${roh.seitenzahl} Seiten` : ''} +
+
+
+
In der Akte seit
+
{formatDatumZeit(roh.erstelltAm)}
+
+ {roh.pruefsumme && ( +
+
+
+
{roh.pruefsumme}
+
+ )} +
+

+

+ + )} +
+
+ + {roh.ablageortPapier && ( + + + Original in Papier + + +

{roh.ablageortPapier}

+

+ Dorthin greifen, wenn jemand das Original sehen will. +

+
+
+ )} +
+
+ + + + Verknüpfungen + + Wozu gehört dieses Schreiben? Über die Verknüpfung taucht es auch beim Vorgang, bei der + Frist oder bei der Buchung auf. + + + + + + + + {schreibrecht && ( + + + Erkannter Text und Vorschläge + + Aus dem gelesenen Text abgeleitet. Nichts davon ist gespeichert, bis du es übernimmst. + + + + + + {roh.ocrText && ( +
+ + + + +
+                  {roh.ocrText}
+                
+
+ )} +
+
+ )} + + ) +} + +/** Date -> "2026-03-12" fuer . */ +function alsFeldwert(datum: Date | null): string { + return datum ? datum.toISOString().slice(0, 10) : '' +} diff --git a/src/lib/dokumente/ziele.ts b/src/lib/dokumente/ziele.ts index c121001..4081293 100644 --- a/src/lib/dokumente/ziele.ts +++ b/src/lib/dokumente/ziele.ts @@ -124,7 +124,7 @@ export async function zieleDerAkte(betreuteId: string): Promise { ziel: 'buchung', eintraege: buchungen.map((b) => ({ id: b.id, - titel: `${formatDatum(b.datum)} · ${b.richtung === 'AUSGANG' ? '−' : '+'}${formatCent( + titel: `${formatDatum(b.datum)} · ${b.richtung === 'AUSGABE' ? '−' : '+'}${formatCent( b.betragCent, )} · ${b.gegenstelle ?? 'ohne Gegenstelle'}`, })), diff --git a/src/lib/pdf/erzeugen.ts b/src/lib/pdf/erzeugen.ts index 7593746..784f962 100644 --- a/src/lib/pdf/erzeugen.ts +++ b/src/lib/pdf/erzeugen.ts @@ -1,206 +1,214 @@ -import 'server-only' -import type * as React from 'react' -import { renderToBuffer } from '@react-pdf/renderer' -import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib' -import { typErkennen } from '@/lib/speicher/dateityp' - -/** - * PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen. - * - * Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2. - * - * Zwei Wege fuehren hier zusammen: - * - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte) - * - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander - * (Belegmappe, Unterlagenpaket fuer das Gericht) - * und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das - * ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann. - */ - -/** A4 in PDF-Punkten (72 dpi). */ -export const A4_BREITE = 595.28 -export const A4_HOEHE = 841.89 -const RAND = 36 - -/** Rendert ein react-pdf-Dokument zu einem Buffer. */ -export async function pdfErzeugen(dokument: React.ReactElement): Promise { - // renderToBuffer erwartet ein -Element; die Typen von - // @react-pdf/renderer sind hier enger als React.ReactElement. - const roh = await renderToBuffer(dokument as Parameters[0]) - return Buffer.from(roh) -} - -/** - * Wandelt ein Bild in ein einseitiges PDF. - * - * JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die - * Formate moderner Handykameras) gehen vorher durch sharp - sonst waere - * jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren. - */ -export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise { - const typ = typErkennen(inhalt) - if (!typ || !typ.istBild) { - throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.') - } - - let daten = inhalt - let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png' - - if (typ.endung !== 'jpg' && typ.endung !== 'png') { - const sharp = (await import('sharp')).default - daten = await sharp(inhalt).rotate().png().toBuffer() - format = 'png' - } - - const pdf = await PDFDocument.create() - const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten) - - const beschriftungshoehe = beschriftung ? 16 : 0 - const nutzbareBreite = A4_BREITE - 2 * RAND - const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe - - // Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde. - const querformat = bild.width > bild.height - const seitenBreite = querformat ? A4_HOEHE : A4_BREITE - const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE - const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite - const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe - - const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1) - const breite = bild.width * massstab - const hoehe = bild.height * massstab - - const seite = pdf.addPage([seitenBreite, seitenHoehe]) - seite.drawImage(bild, { - x: (seitenBreite - breite) / 2, - y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2, - width: breite, - height: hoehe, - }) - - if (beschriftung) { - const schrift = await pdf.embedFont(StandardFonts.Helvetica) - seite.drawText(kuerzenFuerSchrift(beschriftung, 110), { - x: RAND, - y: RAND / 2, - size: 8, - font: schrift, - color: rgb(0.3, 0.3, 0.3), - }) - } - - return Buffer.from(await pdf.save()) -} - -/** - * Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket). - * Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib. - */ -export async function pdfsZusammenfuehren(teile: Buffer[]): Promise { - const ziel = await PDFDocument.create() - ziel.setProducer('Betreuungs-Cockpit') - ziel.setCreator('Betreuungs-Cockpit') - - for (const teil of teile) { - const typ = typErkennen(teil) - const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil - - const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true }) - const seiten = await ziel.copyPages(quelle, quelle.getPageIndices()) - for (const seite of seiten) ziel.addPage(seite) - } - - if (ziel.getPageCount() === 0) { - // Ein leeres PDF laesst sich weder oeffnen noch abheften. - ziel.addPage([A4_BREITE, A4_HOEHE]) - } - - return Buffer.from(await ziel.save()) -} - -/** - * Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der - * Belegmappe gebraucht, bevor zusammengefuehrt wird. - */ -export async function seitenzahl(pdf: Buffer): Promise { - const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) - return dokument.getPageCount() -} - -/** - * Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen. - * - * Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest, - * auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die - * sich das Inhaltsverzeichnis und spaeter das Gericht beziehen. - */ -export async function paginieren(pdf: Buffer, fusszeile: string): Promise { - const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) - const schrift = await dokument.embedFont(StandardFonts.Helvetica) - const seiten = dokument.getPages() - const gesamt = seiten.length - - seiten.forEach((seite, i) => { - const { width, height } = seite.getSize() - const drehung = seite.getRotation().angle % 360 - const zaehlung = `Seite ${i + 1} von ${gesamt}` - const text = kuerzenFuerSchrift(fusszeile, 120) - const groesse = 7.5 - - // Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls - // gedreht, sonst steht sie quer im Blatt. - if (drehung === 90 || drehung === 270) { - seite.drawText(`${text} · ${zaehlung}`, { - x: drehung === 90 ? 18 : width - 18, - y: drehung === 90 ? 20 : height - 20, - size: groesse, - font: schrift, - color: rgb(0.35, 0.35, 0.35), - rotate: degrees(drehung), - }) - return - } - - seite.drawText(text, { - x: 36, - y: 18, - size: groesse, - font: schrift, - color: rgb(0.35, 0.35, 0.35), - }) - const breite = schrift.widthOfTextAtSize(zaehlung, groesse) - seite.drawText(zaehlung, { - x: width - 36 - breite, - y: 18, - size: groesse, - font: schrift, - color: rgb(0.35, 0.35, 0.35), - }) - }) - - return Buffer.from(await dokument.save()) -} - -/** - * Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa - * ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen - - * deshalb hier ersetzen statt riskieren. - */ -export function fuerStandardschrift(text: string): string { - return text - .replace(/[\u2013\u2014]/g, '-') - .replace(/[\u2018\u2019\u201a]/g, "'") - .replace(/[\u201c\u201d\u201e]/g, '"') - .replace(/\u2026/g, '...') - .replace(/\u20ac/g, 'EUR') - .replace(/[\u00a0\u2007\u202f]/g, ' ') - // Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das - // Speichern des PDFs scheitern zu lassen. - // eslint-disable-next-line no-control-regex - .replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?') -} - -function kuerzenFuerSchrift(text: string, maxZeichen: number): string { - const sauber = fuerStandardschrift(text) - return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber -} +import 'server-only' +import type * as React from 'react' +import { renderToBuffer } from '@react-pdf/renderer' +import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib' +import { typErkennen } from '@/lib/speicher/dateityp' +import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift' + +/** + * PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen. + * + * Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2. + * + * Zwei Wege fuehren hier zusammen: + * - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte) + * - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander + * (Belegmappe, Unterlagenpaket fuer das Gericht) + * und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das + * ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann. + */ + +/** A4 in PDF-Punkten (72 dpi). */ +export const A4_BREITE = 595.28 +export const A4_HOEHE = 841.89 +const RAND = 36 + +/** Rendert ein react-pdf-Dokument zu einem Buffer. */ +export async function pdfErzeugen(dokument: React.ReactElement): Promise { + // Muss vor dem ersten Rendern laufen: repariert bei Bedarf die + // Aufloesung der eingebauten Standardschriften. Idempotent und ab dem + // zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts. + standardschriftenSicherstellen() + + // renderToBuffer erwartet ein -Element; die Typen von + // @react-pdf/renderer sind hier enger als React.ReactElement. + const roh = await renderToBuffer(dokument as Parameters[0]) + return Buffer.from(roh) +} + +/** + * Wandelt ein Bild in ein einseitiges PDF. + * + * JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die + * Formate moderner Handykameras) gehen vorher durch sharp - sonst waere + * jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren. + */ +export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise { + const typ = typErkennen(inhalt) + if (!typ || !typ.istBild) { + throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.') + } + + let daten = inhalt + let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png' + + if (typ.endung !== 'jpg' && typ.endung !== 'png') { + const sharp = (await import('sharp')).default + daten = await sharp(inhalt).rotate().png().toBuffer() + format = 'png' + } + + const pdf = await PDFDocument.create() + const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten) + + const beschriftungshoehe = beschriftung ? 16 : 0 + const nutzbareBreite = A4_BREITE - 2 * RAND + const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe + + // Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde. + const querformat = bild.width > bild.height + const seitenBreite = querformat ? A4_HOEHE : A4_BREITE + const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE + const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite + const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe + + const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1) + const breite = bild.width * massstab + const hoehe = bild.height * massstab + + const seite = pdf.addPage([seitenBreite, seitenHoehe]) + seite.drawImage(bild, { + x: (seitenBreite - breite) / 2, + y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2, + width: breite, + height: hoehe, + }) + + if (beschriftung) { + const schrift = await pdf.embedFont(StandardFonts.Helvetica) + seite.drawText(kuerzenFuerSchrift(beschriftung, 110), { + x: RAND, + y: RAND / 2, + size: 8, + font: schrift, + color: rgb(0.3, 0.3, 0.3), + }) + } + + return Buffer.from(await pdf.save()) +} + +/** + * Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket). + * Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib. + */ +export async function pdfsZusammenfuehren(teile: Buffer[]): Promise { + const ziel = await PDFDocument.create() + ziel.setProducer('Betreuungs-Cockpit') + ziel.setCreator('Betreuungs-Cockpit') + + for (const teil of teile) { + const typ = typErkennen(teil) + const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil + + const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true }) + const seiten = await ziel.copyPages(quelle, quelle.getPageIndices()) + for (const seite of seiten) ziel.addPage(seite) + } + + if (ziel.getPageCount() === 0) { + // Ein leeres PDF laesst sich weder oeffnen noch abheften. + ziel.addPage([A4_BREITE, A4_HOEHE]) + } + + return Buffer.from(await ziel.save()) +} + +/** + * Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der + * Belegmappe gebraucht, bevor zusammengefuehrt wird. + */ +export async function seitenzahl(pdf: Buffer): Promise { + const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) + return dokument.getPageCount() +} + +/** + * Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen. + * + * Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest, + * auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die + * sich das Inhaltsverzeichnis und spaeter das Gericht beziehen. + */ +export async function paginieren(pdf: Buffer, fusszeile: string): Promise { + const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) + const schrift = await dokument.embedFont(StandardFonts.Helvetica) + const seiten = dokument.getPages() + const gesamt = seiten.length + + seiten.forEach((seite, i) => { + const { width, height } = seite.getSize() + const drehung = seite.getRotation().angle % 360 + const zaehlung = `Seite ${i + 1} von ${gesamt}` + const text = kuerzenFuerSchrift(fusszeile, 120) + const groesse = 7.5 + + // Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls + // gedreht, sonst steht sie quer im Blatt. + if (drehung === 90 || drehung === 270) { + seite.drawText(`${text} · ${zaehlung}`, { + x: drehung === 90 ? 18 : width - 18, + y: drehung === 90 ? 20 : height - 20, + size: groesse, + font: schrift, + color: rgb(0.35, 0.35, 0.35), + rotate: degrees(drehung), + }) + return + } + + seite.drawText(text, { + x: 36, + y: 18, + size: groesse, + font: schrift, + color: rgb(0.35, 0.35, 0.35), + }) + const breite = schrift.widthOfTextAtSize(zaehlung, groesse) + seite.drawText(zaehlung, { + x: width - 36 - breite, + y: 18, + size: groesse, + font: schrift, + color: rgb(0.35, 0.35, 0.35), + }) + }) + + return Buffer.from(await dokument.save()) +} + +/** + * Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa + * ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen - + * deshalb hier ersetzen statt riskieren. + */ +export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift' + +export function fuerStandardschrift(text: string): string { + return text + .replace(/[\u2013\u2014]/g, '-') + .replace(/[\u2018\u2019\u201a]/g, "'") + .replace(/[\u201c\u201d\u201e]/g, '"') + .replace(/\u2026/g, '...') + .replace(/\u20ac/g, 'EUR') + .replace(/[\u00a0\u2007\u202f]/g, ' ') + // Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das + // Speichern des PDFs scheitern zu lassen. + // eslint-disable-next-line no-control-regex + .replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?') +} + +function kuerzenFuerSchrift(text: string, maxZeichen: number): string { + const sauber = fuerStandardschrift(text) + return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber +} diff --git a/src/lib/pdf/react-pdf-pdfkit.d.ts b/src/lib/pdf/react-pdf-pdfkit.d.ts new file mode 100644 index 0000000..5bbcd47 --- /dev/null +++ b/src/lib/pdf/react-pdf-pdfkit.d.ts @@ -0,0 +1,25 @@ +/** + * Typen fuer `@react-pdf/pdfkit`. + * + * Das Paket bringt keine eigenen Typdeklarationen mit. Benutzt wird es nur + * an einer Stelle - `src/lib/pdf/standardschrift.ts` holt sich daraus die + * Metriken der eingebauten PDF-Standardschriften. Deshalb hier nur die + * schmale Oberflaeche, die dort wirklich gebraucht wird, statt einer + * Nachbildung des ganzen Pakets. + */ +declare module '@react-pdf/pdfkit' { + /** Die von pdfkit angelegte Standardschrift (AFM-Metriken, ohne Einbettung). */ + export type PdfkitStandardschrift = { + /** Liefert die Glyphennamen als Hex und die Vorschubbreiten. */ + encode: (text: string) => [string[], { advanceWidth: number }[]] + font: { characterToGlyph: (codePoint: number) => string } + } + + export default class PDFDocument { + constructor(optionen?: { autoFirstPage?: boolean; [weitere: string]: unknown }) + /** Waehlt eine Schrift aus - bei den 14 Standardnamen ohne Datei. */ + font(name: string): this + /** Die zuletzt gewaehlte Schrift. */ + _font: PdfkitStandardschrift + } +} diff --git a/src/lib/pdf/standardschrift.ts b/src/lib/pdf/standardschrift.ts new file mode 100644 index 0000000..a6fb723 --- /dev/null +++ b/src/lib/pdf/standardschrift.ts @@ -0,0 +1,317 @@ +import 'server-only' +import PDFDocument from '@react-pdf/pdfkit' +import { Font } from '@react-pdf/renderer' + +/** + * Notbehelf: die eingebauten PDF-Standardschriften wieder nutzbar machen. + * + * ## Warum es diese Datei gibt + * + * In der installierten Abhaengigkeitsmischung kann `@react-pdf/renderer` + * **keinen einzigen Buchstaben** setzen - auch nicht ohne jede Stilangabe. + * Jeder Aufruf endet mit "Cannot read properties of undefined (reading + * 'unitsPerEm')". + * + * Ursache ist ein Versionsbruch zwischen festgezurrten und mitgewanderten + * Paketen: + * + * @react-pdf/renderer 4.1.6 (in package.json festgelegt) + * @react-pdf/font 3.1.0 (renderer erwartet ^3.0.1) + * @react-pdf/layout 4.7.1 (renderer erwartet ^4.2.0) + * @react-pdf/textkit 6.4.1 (kommt ueber layout mit) + * + * `@react-pdf/font@3.1.0` liefert fuer die Standardschriften bewusst `null` + * zurueck - frueher hat die Schicht darunter den blossen Namen selbst + * aufgeloest. `@react-pdf/textkit@6.4.1` erwartet dagegen ein echtes + * Schriftobjekt mit `layout()` und wirft bei einem Namen ausdruecklich + * "Invalid font". Zwischen beiden klafft eine Luecke, und in die faellt + * jedes PDF dieser Anwendung - Belegmappe wie Jahresbericht. + * + * ## Was hier passiert + * + * Diese Datei baut das fehlende Schriftobjekt aus dem, was ohnehin da ist: + * `@react-pdf/pdfkit` bringt die Metriken der 14 PDF-Standardschriften seit + * jeher mit (`encode()`, `characterToGlyph()`). Darum liegt hier ein + * duenner Adapter, der textkit genau die Schnittstelle anbietet, die es + * sehen will. Kein zusaetzliches Paket, kein Nachladen aus dem Netz. + * + * ## Wichtig + * + * Das ist eine Kruecke, keine Loesung. Die Loesung ist, die mitgewanderten + * Pakete auf zu renderer@4.1.6 passende Staende festzunageln - siehe + * `docs/schema-wuensche/dokumente.md`. Sobald das geschehen ist, merkt + * `standardschriftenSicherstellen()` das von selbst und haelt sich heraus. + * Die Kruecke baut sich also selbst wieder ab. + */ + +/** Die 14 Standardschriften, die jeder Betrachter ohne Einbettung kennt. */ +export const STANDARDSCHRIFTEN = [ + 'Courier', + 'Courier-Bold', + 'Courier-Oblique', + 'Courier-BoldOblique', + 'Helvetica', + 'Helvetica-Bold', + 'Helvetica-Oblique', + 'Helvetica-BoldOblique', + 'Times-Roman', + 'Times-Bold', + 'Times-Italic', + 'Times-BoldItalic', +] as const + +export type Standardschriftname = (typeof STANDARDSCHRIFTEN)[number] + +const IST_STANDARD = new Set(STANDARDSCHRIFTEN) + +/** Weicher Trennstrich - darf beim Umbruch keine Breite beanspruchen. */ +const WEICHER_TRENNSTRICH = 0x00ad + +type PdfkitSchrift = { + encode: (text: string) => [string[], { advanceWidth: number }[]] + font: { characterToGlyph: (codePoint: number) => string } +} + +type Hilfsdokument = { font: (name: string) => void; _font: PdfkitSchrift } + +/** + * Ein einziges Hilfsdokument fuer alle Standardschriften. pdfkit legt die + * Metriken beim ersten Zugriff an und merkt sie sich - das kostet einmalig + * ein paar Millisekunden und danach nichts mehr. + */ +let hilfsdokument: Hilfsdokument | null = null + +function pdfkitSchrift(name: string): PdfkitSchrift { + hilfsdokument ??= new PDFDocument({ autoFirstPage: false }) as unknown as Hilfsdokument + hilfsdokument.font(name) + return hilfsdokument._font +} + +type Glyphe = { + id: number + codePoints: number[] + isLigature: boolean + name: string + _font: PdfkitSchrift + advanceWidth: number | undefined +} + +/** + * Das Schriftobjekt, das textkit erwartet. + * + * Ober- und Unterlaenge sind die ueblichen Naeherungen fuer die + * Standardschriften - genau genug fuer Zeilenumbruch und Zeilenhoehe in + * einem Bericht. `unitsPerEm` ist bei ihnen immer 1000. + */ +class Standardschrift { + readonly name: string + readonly postscriptName: string + readonly familyName: string + readonly fullName: string + readonly type = 'STANDARD' + readonly numGlyphs = 0 + readonly characterSet: number[] = [] + readonly availableFeatures: string[] = [] + readonly bbox = {} + readonly italicAngle = 0 + readonly underlinePosition = -100 + readonly underlineThickness = 50 + + private readonly quelle: PdfkitSchrift + + constructor(name: string) { + this.name = name + this.postscriptName = name + this.familyName = name + this.fullName = name + this.quelle = pdfkitSchrift(name) + } + + get unitsPerEm(): number { + return 1000 + } + + get ascent(): number { + return 900 + } + + get descent(): number { + if (this.name.startsWith('Times')) return -220 + if (this.name.startsWith('Courier')) return -230 + return -200 + } + + get lineGap(): number { + return 0 + } + + get capHeight(): number { + if (this.name.startsWith('Times')) return 650 + if (this.name.startsWith('Courier')) return 550 + return 690 + } + + get xHeight(): number { + if (this.name.startsWith('Times')) return 440 + if (this.name.startsWith('Courier')) return 390 + return 490 + } + + encode(text: string): [string[], { advanceWidth: number }[]] { + const [kodiert, positionen] = this.quelle.encode(text) + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === WEICHER_TRENNSTRICH && positionen[i]) { + positionen[i]!.advanceWidth = 0 + } + } + return [kodiert, positionen] + } + + layout(text: string) { + const [kodiert, positionen] = this.encode(text) + + const glyphen = kodiert.map((zeichen, i) => { + const glyphe = this.glypheFuer(Number.parseInt(zeichen, 16)) + glyphe.advanceWidth = positionen[i]?.advanceWidth + return glyphe + }) + + return { + positions: positionen, + glyphs: glyphen, + script: 'latin', + language: 'dflt', + direction: 'ltr', + features: {}, + advanceWidth: positionen.reduce((summe, position) => summe + position.advanceWidth, 0), + advanceHeight: 0, + bbox: undefined, + } + } + + glyphForCodePoint(codePoint: number): Glyphe { + const glyphe = this.glypheFuer(codePoint) + glyphe.advanceWidth = 400 + return glyphe + } + + hasGlyphForCodePoint(codePoint: number): boolean { + return this.quelle.font.characterToGlyph(codePoint) !== '.notdef' + } + + private glypheFuer(id: number): Glyphe { + return { + id, + codePoints: [id], + isLigature: false, + name: this.quelle.font.characterToGlyph(id), + _font: this.quelle, + advanceWidth: undefined, + } + } +} + +const zwischenspeicher = new Map() + +function schriftFuer(name: string): Standardschrift { + let schrift = zwischenspeicher.get(name) + if (!schrift) { + schrift = new Standardschrift(name) + zwischenspeicher.set(name, schrift) + } + return schrift +} + +/** + * Loest "Helvetica" plus fett/kursiv zum konkreten Standardnamen auf. Wer + * gleich "Helvetica-Bold" angibt, bekommt genau das zurueck. + */ +export function standardschriftAufloesen( + familie: string, + gewicht?: number | string, + stil?: string, +): string | null { + if (!IST_STANDARD.has(familie)) return null + + const zahl = + typeof gewicht === 'number' + ? gewicht + : typeof gewicht === 'string' && /^\d+$/.test(gewicht) + ? Number.parseInt(gewicht, 10) + : null + + const fett = + zahl !== null + ? zahl >= 600 + : gewicht === 'bold' || gewicht === 'bolder' || gewicht === 'heavy' || gewicht === 'black' + + const kursiv = stil === 'italic' || stil === 'oblique' + + // Nur die drei Grundfamilien werden kombiniert; ein bereits konkreter + // Name wie "Times-Bold" bleibt unveraendert. + if (familie === 'Helvetica') { + if (fett && kursiv) return 'Helvetica-BoldOblique' + if (fett) return 'Helvetica-Bold' + if (kursiv) return 'Helvetica-Oblique' + return 'Helvetica' + } + if (familie === 'Courier') { + if (fett && kursiv) return 'Courier-BoldOblique' + if (fett) return 'Courier-Bold' + if (kursiv) return 'Courier-Oblique' + return 'Courier' + } + if (familie === 'Times-Roman') { + if (fett && kursiv) return 'Times-BoldItalic' + if (fett) return 'Times-Bold' + if (kursiv) return 'Times-Italic' + return 'Times-Roman' + } + + return familie +} + +// ----------------------------------------------------------------------------- + +type Beschreibung = { fontFamily?: string; fontWeight?: number | string; fontStyle?: string } +type Schriftablage = { getFont: (beschreibung: Beschreibung) => { data?: unknown } | null } + +let geprueft = false + +/** Liefert die vorhandene Schriftablage schon ein brauchbares Objekt? */ +function ablageIstHeil(ablage: Schriftablage): boolean { + try { + const treffer = ablage.getFont({ fontFamily: 'Helvetica', fontWeight: 400, fontStyle: 'normal' }) + const daten = treffer?.data as { layout?: unknown } | undefined + return typeof daten?.layout === 'function' + } catch { + return false + } +} + +/** + * Haengt sich vor die Schriftaufloesung von react-pdf - aber nur, wenn es + * noetig ist. Ist die Abhaengigkeitsmischung wieder stimmig, passiert hier + * nichts. + * + * Mehrfach aufrufbar; ab dem zweiten Mal kostet es nichts. + */ +export function standardschriftenSicherstellen(): void { + if (geprueft) return + geprueft = true + + const ablage = Font as unknown as Schriftablage + if (typeof ablage?.getFont !== 'function') return + if (ablageIstHeil(ablage)) return + + const urspruenglich = ablage.getFont.bind(ablage) + + ablage.getFont = (beschreibung: Beschreibung) => { + const familie = beschreibung?.fontFamily + if (familie && IST_STANDARD.has(familie)) { + const name = standardschriftAufloesen(familie, beschreibung.fontWeight, beschreibung.fontStyle) + if (name) return { data: schriftFuer(name) } + } + return urspruenglich(beschreibung) + } +} diff --git a/tests/dokumente.test.ts b/tests/dokumente.test.ts index e7266f9..a202d1e 100644 --- a/tests/dokumente.test.ts +++ b/tests/dokumente.test.ts @@ -1,474 +1,607 @@ -import { describe, expect, it } from 'vitest' -import { - endungPasst, - inhaltBeschreiben, - typErkennen, -} from '@/lib/speicher/dateityp' -import { - betreuteIdAusSchluessel, - schluesselErzeugen, - schluesselGueltig, - UngueltigerSchluessel, - zugriffTokenErzeugen, - zugriffTokenPruefen, -} from '@/lib/speicher/schluessel' -import { - aktenzeichenErkennen, - alleAktenzeichen, - absenderErkennen, - datenFinden, - dokumentartErkennen, - erkennungsvorschlaege, - fristenErkennen, - schreibdatumErkennen, -} from '@/lib/dokumente/erkennung' -import { suchanfrageAufbereiten } from '@/lib/dokumente/suchbegriff' - -/** - * Diese Tests beschreiben, was fachlich gelten soll - nicht, welche - * Funktion aufgerufen wird. Sie brauchen weder Datenbank noch Netz. - */ - -// ----------------------------------------------------------------------------- -// Hilfen: kleine, gueltige Dateien im Speicher bauen -// ----------------------------------------------------------------------------- - -function pdfDatei(): Buffer { - return Buffer.concat([ - Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n', 'latin1'), - Buffer.from('trailer\n<< /Root 1 0 R >>\n%%EOF\n', 'latin1'), - ]) -} - -function jpegDatei(): Buffer { - return Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64, 0x11)]) -} - -function pngDatei(): Buffer { - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.alloc(64, 0x22), - ]) -} - -function webpDatei(): Buffer { - const kopf = Buffer.alloc(16) - kopf.write('RIFF', 0, 'ascii') - kopf.writeUInt32LE(64, 4) - kopf.write('WEBPVP8 ', 8, 'ascii') - return Buffer.concat([kopf, Buffer.alloc(48, 0x33)]) -} - -function tiffDatei(): Buffer { - return Buffer.concat([Buffer.from([0x49, 0x49, 0x2a, 0x00]), Buffer.alloc(64, 0x44)]) -} - -function heicDatei(): Buffer { - const kopf = Buffer.alloc(24) - kopf.writeUInt32BE(24, 0) - kopf.write('ftypheic', 4, 'ascii') - kopf.write('mif1heic', 12, 'ascii') - return Buffer.concat([kopf, Buffer.alloc(48, 0x55)]) -} - -/** Eine HTML-Seite, die sich als PDF ausgibt. */ -function getarnteHtmlDatei(): Buffer { - return Buffer.from( - '\nrechnung.pdf' + - '', - 'utf8', - ) -} - -// ----------------------------------------------------------------------------- -// Dateityp -// ----------------------------------------------------------------------------- - -describe('Der Dateityp wird am Inhalt entschieden, nicht am Namen', () => { - it('erkennt die sechs erlaubten Formate', () => { - expect(typErkennen(pdfDatei())?.mimeTyp).toBe('application/pdf') - expect(typErkennen(jpegDatei())?.mimeTyp).toBe('image/jpeg') - expect(typErkennen(pngDatei())?.mimeTyp).toBe('image/png') - expect(typErkennen(webpDatei())?.mimeTyp).toBe('image/webp') - expect(typErkennen(tiffDatei())?.mimeTyp).toBe('image/tiff') - expect(typErkennen(heicDatei())?.mimeTyp).toBe('image/heic') - }) - - it('lehnt eine HTML-Seite ab, die sich als PDF ausgibt', () => { - const getarnt = getarnteHtmlDatei() - expect(typErkennen(getarnt)).toBeNull() - expect(inhaltBeschreiben(getarnt)).toBe('eine HTML-Seite') - }) - - it('lehnt ein PDF ab, dem Fremdinhalt vorangestellt wurde', () => { - // Der klassische Polyglot: erst HTML, dann irgendwo %PDF. - const polyglot = Buffer.concat([Buffer.from('x\n'), pdfDatei()]) - expect(typErkennen(polyglot)).toBeNull() - }) - - it('lehnt Office-Dateien, Archive und Programme ab', () => { - const zip = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(32)]) - const exe = Buffer.concat([Buffer.from([0x4d, 0x5a]), Buffer.alloc(32)]) - expect(typErkennen(zip)).toBeNull() - expect(typErkennen(exe)).toBeNull() - expect(inhaltBeschreiben(zip)).toContain('ZIP') - }) - - it('lehnt zu kurze und leere Dateien ab', () => { - expect(typErkennen(Buffer.alloc(0))).toBeNull() - expect(typErkennen(Buffer.from('%PDF'))).toBeNull() - expect(inhaltBeschreiben(Buffer.alloc(0))).toBe('eine leere Datei') - }) - - it('merkt, wenn die Endung nicht zum Inhalt passt', () => { - const jpeg = typErkennen(jpegDatei())! - expect(endungPasst('foto.jpeg', jpeg)).toBe(true) - expect(endungPasst('foto.jpg', jpeg)).toBe(true) - expect(endungPasst('rechnung.pdf', jpeg)).toBe(false) - }) -}) - -// ----------------------------------------------------------------------------- -// Ablageschluessel -// ----------------------------------------------------------------------------- - -describe('Der Ablageschlüssel wird erzeugt und lässt sich nicht ausbrechen', () => { - it('setzt die betreuteId an die zweite Stelle', () => { - const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') - expect(schluessel).toMatch(/^betreute\/clx123abc\/dokumente\/[0-9a-f-]{36}\.pdf$/) - expect(betreuteIdAusSchluessel(schluessel)).toBe('clx123abc') - }) - - it('erzeugt für dieselbe Datei nie zweimal denselben Schlüssel', () => { - const a = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') - const b = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') - expect(a).not.toBe(b) - }) - - it('weist eine Kennung mit Pfadanteilen zurück', () => { - expect(() => schluesselErzeugen('../../etc', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) - expect(() => schluesselErzeugen('a/b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) - expect(() => schluesselErzeugen('a.b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) - }) - - it('erkennt von Hand gebastelte Schlüssel als ungültig', () => { - expect(schluesselGueltig('betreute/clx1/dokumente/../../../etc/passwd')).toBe(false) - expect(schluesselGueltig('betreute/clx1/dokumente/datei.pdf')).toBe(false) - expect(schluesselGueltig('betreute/clx1/geheim/00000000-0000-0000-0000-000000000000.pdf')).toBe( - false, - ) - expect(schluesselGueltig('/etc/passwd')).toBe(false) - expect(schluesselGueltig('betreute\\clx1\\dokumente\\x.pdf')).toBe(false) - expect(schluesselGueltig(null)).toBe(false) - }) - - it('nimmt den Dateinamen des Nutzers nicht in den Pfad auf', () => { - const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') - expect(schluessel).not.toContain('rechnung') - expect(schluessel.split('/')).toHaveLength(4) - }) -}) - -// ----------------------------------------------------------------------------- -// Signierte Links -// ----------------------------------------------------------------------------- - -describe('Signierte Dateilinks laufen ab und lassen sich nicht fälschen', () => { - const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') - const jetzt = new Date('2026-03-12T10:00:00.000Z') - - it('erkennt ein frisches Token an', () => { - const token = zugriffTokenErzeugen(schluessel, 300, jetzt) - const geprueft = zugriffTokenPruefen(token, jetzt) - expect(geprueft.gueltig).toBe(true) - if (geprueft.gueltig) expect(geprueft.schluessel).toBe(schluessel) - }) - - it('weist ein abgelaufenes Token zurück', () => { - const token = zugriffTokenErzeugen(schluessel, 300, jetzt) - const spaeter = new Date(jetzt.getTime() + 301_000) - const geprueft = zugriffTokenPruefen(token, spaeter) - expect(geprueft.gueltig).toBe(false) - if (!geprueft.gueltig) expect(geprueft.grund).toBe('abgelaufen') - }) - - it('weist ein Token mit verlängertem Ablauf zurück', () => { - const token = zugriffTokenErzeugen(schluessel, 60, jetzt) - const [kodiert, ablauf, signatur] = token.split('.') - const verlaengert = `${kodiert}.${Number(ablauf) + 86_400}.${signatur}` - const geprueft = zugriffTokenPruefen(verlaengert, jetzt) - expect(geprueft.gueltig).toBe(false) - if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur') - }) - - it('weist ein Token zurück, in das ein fremder Schlüssel geschoben wurde', () => { - const token = zugriffTokenErzeugen(schluessel, 300, jetzt) - const fremd = schluesselErzeugen('clyFREMD99', 'dokumente', 'pdf') - const [, ablauf, signatur] = token.split('.') - const gebastelt = `${Buffer.from(fremd, 'utf8').toString('base64url')}.${ablauf}.${signatur}` - const geprueft = zugriffTokenPruefen(gebastelt, jetzt) - expect(geprueft.gueltig).toBe(false) - if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur') - }) - - it('weist Unsinn und leere Werte zurück', () => { - for (const wert of ['', 'abc', 'a.b.c', null, undefined, 12]) { - expect(zugriffTokenPruefen(wert as unknown, jetzt).gueltig).toBe(false) - } - }) -}) - -// ----------------------------------------------------------------------------- -// Datum und Aktenzeichen -// ----------------------------------------------------------------------------- - -describe('Datumsangaben im Schreiben werden gelesen', () => { - it('versteht 12.03.2026, 12. März 2026 und 2026-03-12', () => { - expect(datenFinden('Bayreuth, 12.03.2026')[0]?.datum.toISOString()).toBe( - '2026-03-12T00:00:00.000Z', - ) - expect(datenFinden('Bayreuth, 12. März 2026')[0]?.datum.toISOString()).toBe( - '2026-03-12T00:00:00.000Z', - ) - expect(datenFinden('Stand 2026-03-12')[0]?.datum.toISOString()).toBe( - '2026-03-12T00:00:00.000Z', - ) - }) - - it('ergänzt zweistellige Jahre sinnvoll', () => { - expect(datenFinden('am 12.03.26')[0]?.datum.getUTCFullYear()).toBe(2026) - expect(datenFinden('am 12.03.99')[0]?.datum.getUTCFullYear()).toBe(1999) - }) - - it('erfindet keinen 31. Februar', () => { - expect(datenFinden('am 31.02.2026')).toHaveLength(0) - expect(datenFinden('am 45.13.2026')).toHaveLength(0) - }) - - it('nimmt das ausdrücklich benannte Datum des Schreibens', () => { - const text = [ - 'Bezirk Oberfranken', - 'Sozialverwaltung', - '', - 'Bayreuth, 05.03.2026', - 'Ihr Schreiben vom 12.03.2026', - ].join('\n') - const vorschlag = schreibdatumErkennen(text, new Date('2026-06-01T00:00:00.000Z')) - expect(vorschlag?.wert.toISOString()).toBe('2026-03-12T00:00:00.000Z') - expect(vorschlag?.sicherheit).toBe('hoch') - }) - - it('übernimmt kein Datum aus der Zukunft als Briefdatum', () => { - const text = 'Zahlbar bis zum 30.12.2030' - expect(schreibdatumErkennen(text, new Date('2026-03-12T00:00:00.000Z'))).toBeNull() - }) -}) - -describe('Aktenzeichen und Nummern werden erkannt', () => { - it('liest das Aktenzeichen des Betreuungsgerichts', () => { - const vorschlag = aktenzeichenErkennen('Amtsgericht Bayreuth\nXVII 123/25\n\nBeschluss') - expect(vorschlag?.wert).toBe('XVII 123/25') - }) - - it('liest ein beschriftetes Aktenzeichen', () => { - expect(aktenzeichenErkennen('Az.: 4711/26\nBetreff: Hilfe zur Pflege')?.wert).toBe('4711/26') - expect(aktenzeichenErkennen('Aktenzeichen: SG 12 SO 45/26\n')?.wert).toBe('SG 12 SO 45/26') - }) - - it('liest Kunden- und Versicherungsnummern', () => { - expect(aktenzeichenErkennen('Kd.-Nr. 998877\nRechnung')?.wert).toBe('998877') - expect(aktenzeichenErkennen('Versicherungsschein-Nr. LV-4711/2\n')?.wert).toBe('LV-4711/2') - }) - - it('hält eine reine Jahreszahl nicht für ein Aktenzeichen', () => { - expect(aktenzeichenErkennen('Zeitraum 01/2025 bis 12/2025')).toBeNull() - }) - - it('sammelt mehrere Nummern für die Anzeige', () => { - const text = 'Az.: 4711/26\nKundennummer: 998877\n' - expect(alleAktenzeichen(text)).toEqual(expect.arrayContaining(['4711/26', '998877'])) - }) -}) - -// ----------------------------------------------------------------------------- -// Dokumentart und Absender -// ----------------------------------------------------------------------------- - -describe('Die Art des Schreibens wird vorgeschlagen', () => { - it('erkennt einen Widerspruchsbescheid als Bescheid', () => { - const text = 'Widerspruchsbescheid\n\nIhr Antrag auf Hilfe zur Pflege wird abgelehnt.' - expect(dokumentartErkennen(text)?.wert).toBe('BESCHEID') - }) - - it('unterscheidet Mahnung von Rechnung', () => { - expect(dokumentartErkennen('Mahnung\nOffener Rechnungsbetrag 120,00 EUR')?.wert).toBe('MAHNUNG') - expect(dokumentartErkennen('Rechnung\nRechnungsnummer 4711\nZahlbetrag')?.wert).toBe('RECHNUNG') - }) - - it('erkennt Beschluss, Grundbuchauszug und Kaufvertrag', () => { - expect(dokumentartErkennen('Beschluss\nIm Namen des Volkes')?.wert).toBe('BESCHLUSS') - expect(dokumentartErkennen('Grundbuchauszug\nGrundbuch von Bayreuth')?.wert).toBe( - 'GRUNDBUCHAUSZUG', - ) - expect(dokumentartErkennen('Kaufvertrag über das Grundstück')?.wert).toBe('VERTRAG') - }) - - it('gibt nichts vor, wenn nichts erkennbar ist', () => { - expect(dokumentartErkennen('lorem ipsum dolor')).toBeNull() - }) -}) - -describe('Der Absender wird vorgeschlagen', () => { - const brief = [ - 'Bezirk Oberfranken', - 'Sozialverwaltung', - '95440 Bayreuth', - '', - 'Bayreuth, 12.03.2026', - '', - 'Sehr geehrte Frau Wilde,', - ].join('\n') - - it('bevorzugt einen bereits bekannten Absender', () => { - const vorschlag = absenderErkennen(brief, ['Bezirk Oberfranken, Sozialverwaltung']) - expect(vorschlag?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung') - expect(vorschlag?.sicherheit).toBe('hoch') - }) - - it('erkennt sonst die Behörde im Briefkopf', () => { - const vorschlag = absenderErkennen(brief, []) - expect(vorschlag?.wert).toBe('Bezirk Oberfranken') - expect(vorschlag?.sicherheit).toBe('mittel') - }) - - it('hält die Anrede nicht für den Absender', () => { - const vorschlag = absenderErkennen('Sehr geehrte Frau Wilde,\n\nwir teilen Ihnen mit …', []) - expect(vorschlag?.wert ?? '').not.toMatch(/Sehr geehrte/) - }) -}) - -// ----------------------------------------------------------------------------- -// Fristen -// ----------------------------------------------------------------------------- - -describe('Fristen im Text werden vorgeschlagen, nie angelegt', () => { - const zugang = new Date(Date.UTC(2026, 2, 12)) // 12.03.2026 - - it('rechnet "innerhalb von vier Wochen" ab dem Zugangsdatum', () => { - const text = - 'Gegen diesen Bescheid können Sie innerhalb von vier Wochen nach Bekanntgabe ' + - 'Widerspruch einlegen.' - const [frist] = fristenErkennen(text, zugang) - - expect(frist).toBeDefined() - expect(frist!.datum?.toISOString()).toBe('2026-04-09T00:00:00.000Z') - expect(frist!.basis?.toISOString()).toBe('2026-03-12T00:00:00.000Z') - expect(frist!.rechtsbehelf).toBe(true) - expect(frist!.textstelle).toContain('vier Wochen') - }) - - it('versteht "binnen eines Monats" und "einen Monat nach Bekanntgabe"', () => { - const a = fristenErkennen('Widerspruch binnen eines Monats.', zugang)[0] - expect(a?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z') - - const b = fristenErkennen('Der Widerspruch ist einen Monat nach Bekanntgabe zu erheben.', zugang)[0] - expect(b?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z') - }) - - it('übernimmt ein ausdrücklich genanntes Datum', () => { - const [frist] = fristenErkennen('Wir bitten um Zahlung bis zum 30.04.2026.', zugang) - expect(frist?.datum?.toISOString()).toBe('2026-04-30T00:00:00.000Z') - expect(frist?.sicherheit).toBe('hoch') - }) - - it('hält einen Zeitraum nicht für eine Frist', () => { - expect(fristenErkennen('Abrechnung vom 01.01.2025 bis 31.12.2025', zugang)).toHaveLength(0) - }) - - it('liefert ohne Zugangsdatum kein erfundenes Datum', () => { - const [frist] = fristenErkennen('Bitte antworten Sie innerhalb von 14 Tagen.', null) - expect(frist?.datum).toBeNull() - expect(frist?.begruendung).toContain('Eingangsdatum') - }) - - it('nennt bei jedem Vorschlag die Textstelle, auf die er sich stützt', () => { - const fristen = fristenErkennen('Zahlung binnen zwei Wochen erbeten.', zugang) - expect(fristen[0]?.textstelle.length).toBeGreaterThan(0) - expect(fristen[0]?.begruendung).toContain('2 Wochen') - }) -}) - -// ----------------------------------------------------------------------------- -// Alles zusammen -// ----------------------------------------------------------------------------- - -describe('Ein abfotografierter Bescheid liefert einen brauchbaren Vorschlag', () => { - const bescheid = [ - 'Bezirk Oberfranken', - 'Sozialverwaltung', - '95440 Bayreuth', - '', - 'Bayreuth, 05.03.2026', - 'Aktenzeichen: 4711/26', - '', - 'Betreff: Hilfe zur Pflege nach dem SGB XII', - '', - 'Widerspruchsbescheid', - '', - 'Sehr geehrte Frau Wilde,', - 'Ihr Widerspruch wird zurückgewiesen. Gegen diesen Bescheid können Sie innerhalb', - 'von vier Wochen nach Bekanntgabe Klage erheben.', - ].join('\n') - - it('schlägt Absender, Datum, Aktenzeichen, Art und Frist vor', () => { - const ergebnis = erkennungsvorschlaege(bescheid, { - bekannteAbsender: ['Bezirk Oberfranken, Sozialverwaltung'], - zugangAm: new Date(Date.UTC(2026, 2, 9)), - heute: new Date(Date.UTC(2026, 2, 20)), - }) - - expect(ergebnis.absender?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung') - expect(ergebnis.schreibenVom?.wert.toISOString()).toBe('2026-03-05T00:00:00.000Z') - expect(ergebnis.aktenzeichen?.wert).toBe('4711/26') - expect(ergebnis.art?.wert).toBe('BESCHEID') - expect(ergebnis.titel).toBe('Hilfe zur Pflege nach dem SGB XII') - expect(ergebnis.fristen[0]?.datum?.toISOString()).toBe('2026-04-06T00:00:00.000Z') - expect(ergebnis.fristen[0]?.rechtsbehelf).toBe(true) - }) - - it('liefert bei leerem Text keine erfundenen Werte', () => { - const leer = erkennungsvorschlaege('', {}) - expect(leer.absender).toBeNull() - expect(leer.art).toBeNull() - expect(leer.fristen).toEqual([]) - }) -}) - -// ----------------------------------------------------------------------------- -// Suchanfrage -// ----------------------------------------------------------------------------- - -describe('Die Suchanfrage wird für websearch_to_tsquery aufbereitet', () => { - it('lässt die gewohnte Suchmaschinen-Schreibweise stehen', () => { - expect(suchanfrageAufbereiten('"Hilfe zur Pflege" -Mahnung')).toBe('"Hilfe zur Pflege" -Mahnung') - }) - - it('räumt Leerraum und Steuerzeichen weg', () => { - expect(suchanfrageAufbereiten(' Bescheid \n Bezirk \t')).toBe('Bescheid Bezirk') - }) - - it('entfernt ein einzelnes offenes Anführungszeichen', () => { - expect(suchanfrageAufbereiten('"Hilfe zur Pflege')).toBe('Hilfe zur Pflege') - }) - - it('gibt nichts zurück, wenn nichts Suchbares übrig bleibt', () => { - expect(suchanfrageAufbereiten('')).toBeNull() - expect(suchanfrageAufbereiten(' ')).toBeNull() - expect(suchanfrageAufbereiten('a')).toBeNull() - expect(suchanfrageAufbereiten(null)).toBeNull() - }) - - it('begrenzt die Länge, damit niemand die Datenbank beschäftigt', () => { - expect(suchanfrageAufbereiten('x'.repeat(5000))!.length).toBe(200) - }) -}) +import { describe, expect, it } from 'vitest' +import { createElement } from 'react' +import { + endungPasst, + inhaltBeschreiben, + typErkennen, +} from '@/lib/speicher/dateityp' +import { + betreuteIdAusSchluessel, + schluesselErzeugen, + schluesselGueltig, + UngueltigerSchluessel, + zugriffTokenErzeugen, + zugriffTokenPruefen, +} from '@/lib/speicher/schluessel' +import { + aktenzeichenErkennen, + alleAktenzeichen, + absenderErkennen, + datenFinden, + dokumentartErkennen, + erkennungsvorschlaege, + fristenErkennen, + schreibdatumErkennen, +} from '@/lib/dokumente/erkennung' +import { suchanfrageAufbereiten } from '@/lib/dokumente/suchbegriff' +import { + bildAlsPdf, + paginieren, + pdfErzeugen, + pdfsZusammenfuehren, + seitenzahl, +} from '@/lib/pdf/erzeugen' +import { Berichtsdokument } from '@/lib/pdf/rahmen' +import { standardschriftAufloesen } from '@/lib/pdf/standardschrift' +import { ocrLesen } from '@/lib/ocr' +import { pdfSeitenzahlSchaetzen, pdfTextLesen, textAufraeumen } from '@/lib/ocr/pdf-text' + +/** + * Diese Tests beschreiben, was fachlich gelten soll - nicht, welche + * Funktion aufgerufen wird. Sie brauchen weder Datenbank noch Netz. + */ + +// ----------------------------------------------------------------------------- +// Hilfen: kleine, gueltige Dateien im Speicher bauen +// ----------------------------------------------------------------------------- + +function pdfDatei(): Buffer { + return Buffer.concat([ + Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n', 'latin1'), + Buffer.from('trailer\n<< /Root 1 0 R >>\n%%EOF\n', 'latin1'), + ]) +} + +function jpegDatei(): Buffer { + return Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64, 0x11)]) +} + +function pngDatei(): Buffer { + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(64, 0x22), + ]) +} + +function webpDatei(): Buffer { + const kopf = Buffer.alloc(16) + kopf.write('RIFF', 0, 'ascii') + kopf.writeUInt32LE(64, 4) + kopf.write('WEBPVP8 ', 8, 'ascii') + return Buffer.concat([kopf, Buffer.alloc(48, 0x33)]) +} + +function tiffDatei(): Buffer { + return Buffer.concat([Buffer.from([0x49, 0x49, 0x2a, 0x00]), Buffer.alloc(64, 0x44)]) +} + +function heicDatei(): Buffer { + const kopf = Buffer.alloc(24) + kopf.writeUInt32BE(24, 0) + kopf.write('ftypheic', 4, 'ascii') + kopf.write('mif1heic', 12, 'ascii') + return Buffer.concat([kopf, Buffer.alloc(48, 0x55)]) +} + +/** Eine HTML-Seite, die sich als PDF ausgibt. */ +function getarnteHtmlDatei(): Buffer { + return Buffer.from( + '\nrechnung.pdf' + + '', + 'utf8', + ) +} + +// ----------------------------------------------------------------------------- +// Dateityp +// ----------------------------------------------------------------------------- + +describe('Der Dateityp wird am Inhalt entschieden, nicht am Namen', () => { + it('erkennt die sechs erlaubten Formate', () => { + expect(typErkennen(pdfDatei())?.mimeTyp).toBe('application/pdf') + expect(typErkennen(jpegDatei())?.mimeTyp).toBe('image/jpeg') + expect(typErkennen(pngDatei())?.mimeTyp).toBe('image/png') + expect(typErkennen(webpDatei())?.mimeTyp).toBe('image/webp') + expect(typErkennen(tiffDatei())?.mimeTyp).toBe('image/tiff') + expect(typErkennen(heicDatei())?.mimeTyp).toBe('image/heic') + }) + + it('lehnt eine HTML-Seite ab, die sich als PDF ausgibt', () => { + const getarnt = getarnteHtmlDatei() + expect(typErkennen(getarnt)).toBeNull() + expect(inhaltBeschreiben(getarnt)).toBe('eine HTML-Seite') + }) + + it('lehnt ein PDF ab, dem Fremdinhalt vorangestellt wurde', () => { + // Der klassische Polyglot: erst HTML, dann irgendwo %PDF. + const polyglot = Buffer.concat([Buffer.from('x\n'), pdfDatei()]) + expect(typErkennen(polyglot)).toBeNull() + }) + + it('lehnt Office-Dateien, Archive und Programme ab', () => { + const zip = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(32)]) + const exe = Buffer.concat([Buffer.from([0x4d, 0x5a]), Buffer.alloc(32)]) + expect(typErkennen(zip)).toBeNull() + expect(typErkennen(exe)).toBeNull() + expect(inhaltBeschreiben(zip)).toContain('ZIP') + }) + + it('lehnt zu kurze und leere Dateien ab', () => { + expect(typErkennen(Buffer.alloc(0))).toBeNull() + expect(typErkennen(Buffer.from('%PDF'))).toBeNull() + expect(inhaltBeschreiben(Buffer.alloc(0))).toBe('eine leere Datei') + }) + + it('merkt, wenn die Endung nicht zum Inhalt passt', () => { + const jpeg = typErkennen(jpegDatei())! + expect(endungPasst('foto.jpeg', jpeg)).toBe(true) + expect(endungPasst('foto.jpg', jpeg)).toBe(true) + expect(endungPasst('rechnung.pdf', jpeg)).toBe(false) + }) +}) + +// ----------------------------------------------------------------------------- +// Ablageschluessel +// ----------------------------------------------------------------------------- + +describe('Der Ablageschlüssel wird erzeugt und lässt sich nicht ausbrechen', () => { + it('setzt die betreuteId an die zweite Stelle', () => { + const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') + expect(schluessel).toMatch(/^betreute\/clx123abc\/dokumente\/[0-9a-f-]{36}\.pdf$/) + expect(betreuteIdAusSchluessel(schluessel)).toBe('clx123abc') + }) + + it('erzeugt für dieselbe Datei nie zweimal denselben Schlüssel', () => { + const a = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') + const b = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') + expect(a).not.toBe(b) + }) + + it('weist eine Kennung mit Pfadanteilen zurück', () => { + expect(() => schluesselErzeugen('../../etc', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) + expect(() => schluesselErzeugen('a/b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) + expect(() => schluesselErzeugen('a.b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) + }) + + it('erkennt von Hand gebastelte Schlüssel als ungültig', () => { + expect(schluesselGueltig('betreute/clx1/dokumente/../../../etc/passwd')).toBe(false) + expect(schluesselGueltig('betreute/clx1/dokumente/datei.pdf')).toBe(false) + expect(schluesselGueltig('betreute/clx1/geheim/00000000-0000-0000-0000-000000000000.pdf')).toBe( + false, + ) + expect(schluesselGueltig('/etc/passwd')).toBe(false) + expect(schluesselGueltig('betreute\\clx1\\dokumente\\x.pdf')).toBe(false) + expect(schluesselGueltig(null)).toBe(false) + }) + + it('nimmt den Dateinamen des Nutzers nicht in den Pfad auf', () => { + const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') + expect(schluessel).not.toContain('rechnung') + expect(schluessel.split('/')).toHaveLength(4) + }) +}) + +// ----------------------------------------------------------------------------- +// Signierte Links +// ----------------------------------------------------------------------------- + +describe('Signierte Dateilinks laufen ab und lassen sich nicht fälschen', () => { + const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') + const jetzt = new Date('2026-03-12T10:00:00.000Z') + + it('erkennt ein frisches Token an', () => { + const token = zugriffTokenErzeugen(schluessel, 300, jetzt) + const geprueft = zugriffTokenPruefen(token, jetzt) + expect(geprueft.gueltig).toBe(true) + if (geprueft.gueltig) expect(geprueft.schluessel).toBe(schluessel) + }) + + it('weist ein abgelaufenes Token zurück', () => { + const token = zugriffTokenErzeugen(schluessel, 300, jetzt) + const spaeter = new Date(jetzt.getTime() + 301_000) + const geprueft = zugriffTokenPruefen(token, spaeter) + expect(geprueft.gueltig).toBe(false) + if (!geprueft.gueltig) expect(geprueft.grund).toBe('abgelaufen') + }) + + it('weist ein Token mit verlängertem Ablauf zurück', () => { + const token = zugriffTokenErzeugen(schluessel, 60, jetzt) + const [kodiert, ablauf, signatur] = token.split('.') + const verlaengert = `${kodiert}.${Number(ablauf) + 86_400}.${signatur}` + const geprueft = zugriffTokenPruefen(verlaengert, jetzt) + expect(geprueft.gueltig).toBe(false) + if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur') + }) + + it('weist ein Token zurück, in das ein fremder Schlüssel geschoben wurde', () => { + const token = zugriffTokenErzeugen(schluessel, 300, jetzt) + const fremd = schluesselErzeugen('clyFREMD99', 'dokumente', 'pdf') + const [, ablauf, signatur] = token.split('.') + const gebastelt = `${Buffer.from(fremd, 'utf8').toString('base64url')}.${ablauf}.${signatur}` + const geprueft = zugriffTokenPruefen(gebastelt, jetzt) + expect(geprueft.gueltig).toBe(false) + if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur') + }) + + it('weist Unsinn und leere Werte zurück', () => { + for (const wert of ['', 'abc', 'a.b.c', null, undefined, 12]) { + expect(zugriffTokenPruefen(wert as unknown, jetzt).gueltig).toBe(false) + } + }) +}) + +// ----------------------------------------------------------------------------- +// Datum und Aktenzeichen +// ----------------------------------------------------------------------------- + +describe('Datumsangaben im Schreiben werden gelesen', () => { + it('versteht 12.03.2026, 12. März 2026 und 2026-03-12', () => { + expect(datenFinden('Bayreuth, 12.03.2026')[0]?.datum.toISOString()).toBe( + '2026-03-12T00:00:00.000Z', + ) + expect(datenFinden('Bayreuth, 12. März 2026')[0]?.datum.toISOString()).toBe( + '2026-03-12T00:00:00.000Z', + ) + expect(datenFinden('Stand 2026-03-12')[0]?.datum.toISOString()).toBe( + '2026-03-12T00:00:00.000Z', + ) + }) + + it('ergänzt zweistellige Jahre sinnvoll', () => { + expect(datenFinden('am 12.03.26')[0]?.datum.getUTCFullYear()).toBe(2026) + expect(datenFinden('am 12.03.99')[0]?.datum.getUTCFullYear()).toBe(1999) + }) + + it('erfindet keinen 31. Februar', () => { + expect(datenFinden('am 31.02.2026')).toHaveLength(0) + expect(datenFinden('am 45.13.2026')).toHaveLength(0) + }) + + it('nimmt das ausdrücklich benannte Datum des Schreibens', () => { + const text = [ + 'Bezirk Oberfranken', + 'Sozialverwaltung', + '', + 'Bayreuth, 05.03.2026', + 'Ihr Schreiben vom 12.03.2026', + ].join('\n') + const vorschlag = schreibdatumErkennen(text, new Date('2026-06-01T00:00:00.000Z')) + expect(vorschlag?.wert.toISOString()).toBe('2026-03-12T00:00:00.000Z') + expect(vorschlag?.sicherheit).toBe('hoch') + }) + + it('übernimmt kein Datum aus der Zukunft als Briefdatum', () => { + const text = 'Zahlbar bis zum 30.12.2030' + expect(schreibdatumErkennen(text, new Date('2026-03-12T00:00:00.000Z'))).toBeNull() + }) +}) + +describe('Aktenzeichen und Nummern werden erkannt', () => { + it('liest das Aktenzeichen des Betreuungsgerichts', () => { + const vorschlag = aktenzeichenErkennen('Amtsgericht Bayreuth\nXVII 123/25\n\nBeschluss') + expect(vorschlag?.wert).toBe('XVII 123/25') + }) + + it('liest ein beschriftetes Aktenzeichen', () => { + expect(aktenzeichenErkennen('Az.: 4711/26\nBetreff: Hilfe zur Pflege')?.wert).toBe('4711/26') + expect(aktenzeichenErkennen('Aktenzeichen: SG 12 SO 45/26\n')?.wert).toBe('SG 12 SO 45/26') + }) + + it('liest Kunden- und Versicherungsnummern', () => { + expect(aktenzeichenErkennen('Kd.-Nr. 998877\nRechnung')?.wert).toBe('998877') + expect(aktenzeichenErkennen('Versicherungsschein-Nr. LV-4711/2\n')?.wert).toBe('LV-4711/2') + }) + + it('hält eine reine Jahreszahl nicht für ein Aktenzeichen', () => { + expect(aktenzeichenErkennen('Zeitraum 01/2025 bis 12/2025')).toBeNull() + }) + + it('sammelt mehrere Nummern für die Anzeige', () => { + const text = 'Az.: 4711/26\nKundennummer: 998877\n' + expect(alleAktenzeichen(text)).toEqual(expect.arrayContaining(['4711/26', '998877'])) + }) +}) + +// ----------------------------------------------------------------------------- +// Dokumentart und Absender +// ----------------------------------------------------------------------------- + +describe('Die Art des Schreibens wird vorgeschlagen', () => { + it('erkennt einen Widerspruchsbescheid als Bescheid', () => { + const text = 'Widerspruchsbescheid\n\nIhr Antrag auf Hilfe zur Pflege wird abgelehnt.' + expect(dokumentartErkennen(text)?.wert).toBe('BESCHEID') + }) + + it('unterscheidet Mahnung von Rechnung', () => { + expect(dokumentartErkennen('Mahnung\nOffener Rechnungsbetrag 120,00 EUR')?.wert).toBe('MAHNUNG') + expect(dokumentartErkennen('Rechnung\nRechnungsnummer 4711\nZahlbetrag')?.wert).toBe('RECHNUNG') + }) + + it('erkennt Beschluss, Grundbuchauszug und Kaufvertrag', () => { + expect(dokumentartErkennen('Beschluss\nIm Namen des Volkes')?.wert).toBe('BESCHLUSS') + expect(dokumentartErkennen('Grundbuchauszug\nGrundbuch von Bayreuth')?.wert).toBe( + 'GRUNDBUCHAUSZUG', + ) + expect(dokumentartErkennen('Kaufvertrag über das Grundstück')?.wert).toBe('VERTRAG') + }) + + it('gibt nichts vor, wenn nichts erkennbar ist', () => { + expect(dokumentartErkennen('lorem ipsum dolor')).toBeNull() + }) +}) + +describe('Der Absender wird vorgeschlagen', () => { + const brief = [ + 'Bezirk Oberfranken', + 'Sozialverwaltung', + '95440 Bayreuth', + '', + 'Bayreuth, 12.03.2026', + '', + 'Sehr geehrte Frau Wilde,', + ].join('\n') + + it('bevorzugt einen bereits bekannten Absender', () => { + const vorschlag = absenderErkennen(brief, ['Bezirk Oberfranken, Sozialverwaltung']) + expect(vorschlag?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung') + expect(vorschlag?.sicherheit).toBe('hoch') + }) + + it('erkennt sonst die Behörde im Briefkopf', () => { + const vorschlag = absenderErkennen(brief, []) + expect(vorschlag?.wert).toBe('Bezirk Oberfranken') + expect(vorschlag?.sicherheit).toBe('mittel') + }) + + it('hält die Anrede nicht für den Absender', () => { + const vorschlag = absenderErkennen('Sehr geehrte Frau Wilde,\n\nwir teilen Ihnen mit …', []) + expect(vorschlag?.wert ?? '').not.toMatch(/Sehr geehrte/) + }) +}) + +// ----------------------------------------------------------------------------- +// Fristen +// ----------------------------------------------------------------------------- + +describe('Fristen im Text werden vorgeschlagen, nie angelegt', () => { + const zugang = new Date(Date.UTC(2026, 2, 12)) // 12.03.2026 + + it('rechnet "innerhalb von vier Wochen" ab dem Zugangsdatum', () => { + const text = + 'Gegen diesen Bescheid können Sie innerhalb von vier Wochen nach Bekanntgabe ' + + 'Widerspruch einlegen.' + const [frist] = fristenErkennen(text, zugang) + + expect(frist).toBeDefined() + expect(frist!.datum?.toISOString()).toBe('2026-04-09T00:00:00.000Z') + expect(frist!.basis?.toISOString()).toBe('2026-03-12T00:00:00.000Z') + expect(frist!.rechtsbehelf).toBe(true) + expect(frist!.textstelle).toContain('vier Wochen') + }) + + it('versteht "binnen eines Monats" und "einen Monat nach Bekanntgabe"', () => { + const a = fristenErkennen('Widerspruch binnen eines Monats.', zugang)[0] + expect(a?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z') + + const b = fristenErkennen('Der Widerspruch ist einen Monat nach Bekanntgabe zu erheben.', zugang)[0] + expect(b?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z') + }) + + it('übernimmt ein ausdrücklich genanntes Datum', () => { + const [frist] = fristenErkennen('Wir bitten um Zahlung bis zum 30.04.2026.', zugang) + expect(frist?.datum?.toISOString()).toBe('2026-04-30T00:00:00.000Z') + expect(frist?.sicherheit).toBe('hoch') + }) + + it('hält einen Zeitraum nicht für eine Frist', () => { + expect(fristenErkennen('Abrechnung vom 01.01.2025 bis 31.12.2025', zugang)).toHaveLength(0) + }) + + it('liefert ohne Zugangsdatum kein erfundenes Datum', () => { + const [frist] = fristenErkennen('Bitte antworten Sie innerhalb von 14 Tagen.', null) + expect(frist?.datum).toBeNull() + expect(frist?.begruendung).toContain('Eingangsdatum') + }) + + it('nennt bei jedem Vorschlag die Textstelle, auf die er sich stützt', () => { + const fristen = fristenErkennen('Zahlung binnen zwei Wochen erbeten.', zugang) + expect(fristen[0]?.textstelle.length).toBeGreaterThan(0) + expect(fristen[0]?.begruendung).toContain('2 Wochen') + }) +}) + +// ----------------------------------------------------------------------------- +// Alles zusammen +// ----------------------------------------------------------------------------- + +describe('Ein abfotografierter Bescheid liefert einen brauchbaren Vorschlag', () => { + const bescheid = [ + 'Bezirk Oberfranken', + 'Sozialverwaltung', + '95440 Bayreuth', + '', + 'Bayreuth, 05.03.2026', + 'Aktenzeichen: 4711/26', + '', + 'Betreff: Hilfe zur Pflege nach dem SGB XII', + '', + 'Widerspruchsbescheid', + '', + 'Sehr geehrte Frau Wilde,', + 'Ihr Widerspruch wird zurückgewiesen. Gegen diesen Bescheid können Sie innerhalb', + 'von vier Wochen nach Bekanntgabe Klage erheben.', + ].join('\n') + + it('schlägt Absender, Datum, Aktenzeichen, Art und Frist vor', () => { + const ergebnis = erkennungsvorschlaege(bescheid, { + bekannteAbsender: ['Bezirk Oberfranken, Sozialverwaltung'], + zugangAm: new Date(Date.UTC(2026, 2, 9)), + heute: new Date(Date.UTC(2026, 2, 20)), + }) + + expect(ergebnis.absender?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung') + expect(ergebnis.schreibenVom?.wert.toISOString()).toBe('2026-03-05T00:00:00.000Z') + expect(ergebnis.aktenzeichen?.wert).toBe('4711/26') + expect(ergebnis.art?.wert).toBe('BESCHEID') + expect(ergebnis.titel).toBe('Hilfe zur Pflege nach dem SGB XII') + expect(ergebnis.fristen[0]?.datum?.toISOString()).toBe('2026-04-06T00:00:00.000Z') + expect(ergebnis.fristen[0]?.rechtsbehelf).toBe(true) + }) + + it('liefert bei leerem Text keine erfundenen Werte', () => { + const leer = erkennungsvorschlaege('', {}) + expect(leer.absender).toBeNull() + expect(leer.art).toBeNull() + expect(leer.fristen).toEqual([]) + }) +}) + +// ----------------------------------------------------------------------------- +// Suchanfrage +// ----------------------------------------------------------------------------- + +describe('Die Suchanfrage wird für websearch_to_tsquery aufbereitet', () => { + it('lässt die gewohnte Suchmaschinen-Schreibweise stehen', () => { + expect(suchanfrageAufbereiten('"Hilfe zur Pflege" -Mahnung')).toBe('"Hilfe zur Pflege" -Mahnung') + }) + + it('räumt Leerraum und Steuerzeichen weg', () => { + expect(suchanfrageAufbereiten(' Bescheid \n Bezirk \t')).toBe('Bescheid Bezirk') + }) + + it('entfernt ein einzelnes offenes Anführungszeichen', () => { + expect(suchanfrageAufbereiten('"Hilfe zur Pflege')).toBe('Hilfe zur Pflege') + }) + + it('gibt nichts zurück, wenn nichts Suchbares übrig bleibt', () => { + expect(suchanfrageAufbereiten('')).toBeNull() + expect(suchanfrageAufbereiten(' ')).toBeNull() + expect(suchanfrageAufbereiten('a')).toBeNull() + expect(suchanfrageAufbereiten(null)).toBeNull() + }) + + it('begrenzt die Länge, damit niemand die Datenbank beschäftigt', () => { + expect(suchanfrageAufbereiten('x'.repeat(5000))!.length).toBe(200) + }) +}) + +// ----------------------------------------------------------------------------- +// PDF-Werkzeuge +// ----------------------------------------------------------------------------- + +/** Ein gueltiges 1x1-PNG - genug, um den Bildweg zu pruefen. */ +const EIN_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +) + +describe('Die eingebauten Schriften werden aufgelöst', () => { + it('macht aus Familie plus Auszeichnung den konkreten Schriftnamen', () => { + expect(standardschriftAufloesen('Helvetica')).toBe('Helvetica') + expect(standardschriftAufloesen('Helvetica', 'bold')).toBe('Helvetica-Bold') + expect(standardschriftAufloesen('Helvetica', 700)).toBe('Helvetica-Bold') + expect(standardschriftAufloesen('Helvetica', 400, 'italic')).toBe('Helvetica-Oblique') + expect(standardschriftAufloesen('Times-Roman', 'bold', 'italic')).toBe('Times-BoldItalic') + }) + + it('lässt einen bereits konkreten Namen unverändert', () => { + expect(standardschriftAufloesen('Courier-Bold')).toBe('Courier-Bold') + }) + + it('gibt nichts zurück für Schriften, die nicht eingebaut sind', () => { + expect(standardschriftAufloesen('Arial')).toBeNull() + }) +}) + +describe('Aus einem Bericht und einem Beleg wird eine Mappe', () => { + it('setzt einen Bericht mit Umlauten, ohne eine Schrift nachzuladen', async () => { + const bericht = await pdfErzeugen( + createElement(Berichtsdokument, { + kopf: { + titel: 'Vermögensübersicht', + betreute: { name: 'Erika Mustermann', geburtsdatum: new Date(Date.UTC(1940, 0, 1)) }, + aktenzeichen: 'XVII 123/25', + erstelltAm: new Date(Date.UTC(2026, 2, 12)), + erstelltVon: 'Florian Wilde', + }, + mitUnterschrift: true, + children: null, + }), + ) + + expect(bericht.subarray(0, 5).toString('latin1')).toBe('%PDF-') + expect(await seitenzahl(bericht)).toBe(1) + }) + + it('wandelt ein Foto in eine einzelne PDF-Seite', async () => { + const alsPdf = await bildAlsPdf(EIN_PIXEL_PNG, 'Beleg 1 · 12.03.2026') + expect(await seitenzahl(alsPdf)).toBe(1) + }) + + it('führt Bericht und Foto zu einer Mappe zusammen und nummeriert sie durch', async () => { + const deckblatt = await pdfErzeugen( + createElement(Berichtsdokument, { + kopf: { + titel: 'Belegmappe', + betreute: { name: 'Erika Mustermann', geburtsdatum: null }, + erstelltAm: new Date(Date.UTC(2026, 2, 12)), + erstelltVon: 'Florian Wilde', + }, + children: null, + }), + ) + + // Bilder dürfen direkt mitgegeben werden - sie werden unterwegs gewandelt. + const mappe = await pdfsZusammenfuehren([deckblatt, EIN_PIXEL_PNG]) + expect(await seitenzahl(mappe)).toBe(2) + + const paginiert = await paginieren(mappe, 'Belegmappe · Mustermann · Az. XVII 123/25') + expect(await seitenzahl(paginiert)).toBe(2) + expect(paginiert.subarray(0, 5).toString('latin1')).toBe('%PDF-') + }) +}) + +// ----------------------------------------------------------------------------- +// Texterkennung +// ----------------------------------------------------------------------------- + +describe('Die Texterkennung lässt einen Upload nie scheitern', () => { + it('wirft nicht, wenn Tesseract auf diesem Rechner fehlt', async () => { + // Auf dem Windows-Entwicklungsrechner ist Tesseract in der Regel nicht + // installiert. Dann muss ein Status zurückkommen, keine Ausnahme – das + // Dokument gehört auch ohne Volltext in die Akte. + const ergebnis = await ocrLesen(pdfDatei(), 'application/pdf') + + expect(['fertig', 'fehlgeschlagen', 'nicht_verfuegbar']).toContain(ergebnis.status) + expect(typeof ergebnis.text).toBe('string') + }, 30_000) + + it('wirft auch bei einer Datei nicht, aus der kein Text zu holen ist', async () => { + const ergebnis = await ocrLesen(getarnteHtmlDatei(), 'application/pdf') + + expect(ergebnis.status).toBe('nicht_verfuegbar') + expect(ergebnis.text).toBe('') + }, 30_000) +}) + +describe('Eingebetteter PDF-Text wird gelesen, wenn nichts anderes geht', () => { + it('liefert eine leere Zeichenkette statt eines Fehlers, wenn nichts drinsteht', () => { + expect(pdfTextLesen(pdfDatei())).toBe('') + expect(pdfTextLesen(Buffer.alloc(0))).toBe('') + }) + + it('schätzt die Seitenzahl aus der PDF-Struktur', () => { + const mitZaehler = Buffer.from( + ['%PDF-1.7', '1 0 obj', '/Type /Pages /Count 7 >>', ''].join('\n'), + 'latin1', + ) + expect(pdfSeitenzahlSchaetzen(mitZaehler)).toBe(7) + }) + + it('räumt zerrissenen OCR-Text auf, ohne die Zeilen zu verlieren', () => { + // Wagenrücklauf, Mehrfachleerzeichen und Tabulatoren, wie OCR sie liefert. + const roh = ['Bezirk Oberfranken', '', '', ' Sozialverwaltung \t'].join('\r\n') + + expect(textAufraeumen(roh)).toBe(['Bezirk Oberfranken', '', 'Sozialverwaltung'].join('\n')) + }) +})