From 56d4cca8c9e97dad2adc6c01220e1cfc4699df88 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 22 Aug 2026 06:21:27 +0200 Subject: [PATCH] feat: Dokumentenablage, OCR, Volltextsuche und Belegmappe Etappe 3. Dazu die geteilte Infrastruktur, auf der die Berichte der folgenden Etappen aufsetzen: Speichertreiber und PDF-Grundgeruest. Dateiablage hinter einer Schnittstelle, zwei Treiber - lokales Verzeichnis fuer die Entwicklung, S3-kompatibel fuer den Betrieb. Der Schluessel wird erzeugt, nie aus dem Dateinamen abgeleitet; ein Pfadausbruch ueber den Namen ist damit ausgeschlossen. Der Dateityp wird ausschliesslich an den Magic Bytes geprueft, und ein PDF muss bei Offset 0 stehen - damit fallen auch Polyglot-Dateien durch, die gleichzeitig gueltiges PDF und gueltiges HTML sind. Ausgeliefert wird nur ueber kurzlebige signierte URLs, immer als Anhang und mit nosniff. Die Route prueft Signatur, Anmeldung UND Zugriff auf die betreuteId aus dem Schluessel; jeder Abruf steht im Audit-Log. Beim Dateitreiber liegt die Datei zusaetzlich AES-256-GCM-verschluesselt. Beim S3-Treiber geht das nicht, weil signierte URLs am Server vorbei ausgeliefert werden - das ist in der Datei dokumentiert, nicht stillschweigend anders gemacht. @react-pdf/renderer konnte in diesem Abhaengigkeitsbaum keinen einzigen Buchstaben setzen: die Unterabhaengigkeiten sind weitergewandert und liefern fuer Standardschriften kein Schriftobjekt mehr. src/lib/pdf/standardschrift.ts baut es aus den Metriken, die pdfkit ohnehin mitbringt - kein neues Paket, kein Netz - und haengt sich nur ein, wenn die Aufloesung tatsaechlich kaputt ist. Fristerkennung im OCR-Text ("innerhalb von vier Wochen", "binnen eines Monats") wird immer nur VORGESCHLAGEN, nie automatisch angelegt. Die Textstelle steht daneben, damit man nachprueft, bevor man uebernimmt. Gerechnet wird auf lokaler Mittagszeit: in UTC verschoebe die Sommerzeit "vier Wochen ab 12.03." um einen Tag. 57 Tests. Etappe: 3 Phase: Papier --- docs/schema-wuensche/dokumente.md | 187 +++++ src/app/(app)/dokumente/[id]/page.tsx | 257 ++++++ src/lib/dokumente/ziele.ts | 2 +- src/lib/pdf/erzeugen.ts | 420 +++++----- src/lib/pdf/react-pdf-pdfkit.d.ts | 25 + src/lib/pdf/standardschrift.ts | 317 ++++++++ tests/dokumente.test.ts | 1081 ++++++++++++++----------- 7 files changed, 1608 insertions(+), 681 deletions(-) create mode 100644 docs/schema-wuensche/dokumente.md create mode 100644 src/app/(app)/dokumente/[id]/page.tsx create mode 100644 src/lib/pdf/react-pdf-pdfkit.d.ts create mode 100644 src/lib/pdf/standardschrift.ts 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')) + }) +})