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
This commit is contained in:
Florian Wilde
2026-08-22 06:21:27 +02:00
parent 1234a57fa6
commit 56d4cca8c9
7 changed files with 1608 additions and 681 deletions
+187
View File
@@ -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.
+257
View File
@@ -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<typeof eintrag> => eintrag !== null)
const istBild = Boolean(roh.mimeTyp?.startsWith('image/'))
return (
<div className="space-y-6">
<SeitenKopf
titel={roh.titel}
beschreibung={
<div className="flex flex-wrap items-center gap-1.5">
<ArtAbzeichen art={roh.art} />
<RichtungAbzeichen richtung={roh.richtung} />
<PosteingangAbzeichen status={roh.posteingangStatus} />
<OcrAbzeichen status={roh.ocrStatus} />
{roh.tags.map((tag) => (
<TagAbzeichen key={tag.id} name={tag.name} />
))}
</div>
}
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: roh.titel }]}
/>
<Card>
<CardHeader>
<CardTitle>Stand im Posteingang</CardTitle>
<CardDescription>
Wo steht dieses Schreiben gerade? Gesichtet heißt gelesen, zugeordnet heißt: es hängt an
einem Vorgang.
</CardDescription>
</CardHeader>
<CardContent>
<PosteingangStufen
dokumentId={roh.id}
status={roh.posteingangStatus}
schreibrecht={schreibrecht}
/>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben zum Dokument</CardTitle>
<CardDescription>
Was hier steht, findest du später über die Suche wieder.
</CardDescription>
</CardHeader>
<CardContent>
<MetadatenFormular
schreibrecht={schreibrecht}
tags={tags}
werte={{
id: roh.id,
titel: roh.titel,
art: roh.art,
richtung: roh.richtung,
schreibenVom: alsFeldwert(roh.schreibenVom),
eingangAm: alsFeldwert(roh.eingangAm),
versandAm: alsFeldwert(roh.versandAm),
absender: roh.absender ?? '',
empfaenger: roh.empfaenger ?? '',
aktenzeichen: roh.aktenzeichen ?? '',
ablageortPapier: roh.ablageortPapier ?? '',
wiedervorlageAm: alsFeldwert(roh.wiedervorlageAm),
ablaufAm: alsFeldwert(roh.ablaufAm),
posteingangStatus: roh.posteingangStatus,
notizen: dokument.notizen ?? '',
tagIds: roh.tags.map((tag) => tag.id),
}}
/>
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Die Datei</CardTitle>
<CardDescription>
Wird nur über eine kurzlebige, signierte Adresse ausgeliefert – jeder Abruf steht im
Protokoll.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<DateiAktionen
dokumentId={roh.id}
istBild={istBild}
hatDatei={Boolean(roh.objektSchluessel)}
schreibrecht={schreibrecht}
/>
{roh.objektSchluessel && (
<>
<Separator />
<dl className="space-y-2 text-sm">
<div>
<dt className="text-muted-foreground">Dateiname</dt>
<dd className="break-all font-medium">{roh.dateiname ?? '–'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Größe und Umfang</dt>
<dd className="font-medium">
{formatBytes(roh.groesseBytes ?? 0)}
{roh.seitenzahl ? ` · ${roh.seitenzahl} Seiten` : ''}
</dd>
</div>
<div>
<dt className="text-muted-foreground">In der Akte seit</dt>
<dd className="font-medium">{formatDatumZeit(roh.erstelltAm)}</dd>
</div>
{roh.pruefsumme && (
<div>
<dt className="flex items-center gap-1.5 text-muted-foreground">
<Fingerprint className="size-3.5" aria-hidden="true" />
Prüfsumme (SHA-256)
</dt>
<dd className="break-all font-mono text-xs">{roh.pruefsumme}</dd>
</div>
)}
</dl>
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<FileCheck2 className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
Mit dieser Prüfsumme lässt sich belegen, dass die Datei seit der Aufnahme in die
Akte unverändert ist.
</p>
</>
)}
</CardContent>
</Card>
{roh.ablageortPapier && (
<Card>
<CardHeader>
<CardTitle>Original in Papier</CardTitle>
</CardHeader>
<CardContent>
<p className="font-medium">{roh.ablageortPapier}</p>
<p className="mt-1 text-sm text-muted-foreground">
Dorthin greifen, wenn jemand das Original sehen will.
</p>
</CardContent>
</Card>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Verknüpfungen</CardTitle>
<CardDescription>
Wozu gehört dieses Schreiben? Über die Verknüpfung taucht es auch beim Vorgang, bei der
Frist oder bei der Buchung auf.
</CardDescription>
</CardHeader>
<CardContent>
<Verknuepfungen
dokumentId={roh.id}
vorhandene={verknuepfungen}
auswahl={ziele}
schreibrecht={schreibrecht}
/>
</CardContent>
</Card>
{schreibrecht && (
<Card>
<CardHeader>
<CardTitle>Erkannter Text und Vorschläge</CardTitle>
<CardDescription>
Aus dem gelesenen Text abgeleitet. Nichts davon ist gespeichert, bis du es übernimmst.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ErkennungPanel dokumentId={roh.id} vorgaenge={vorgaenge} />
{roh.ocrText && (
<details className="rounded-lg border">
<summary className="cursor-pointer px-4 py-3 font-medium">
<span className="inline-flex items-center gap-2">
<ScanText className="size-4" aria-hidden="true" />
Gelesenen Text anzeigen
</span>
</summary>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap border-t px-4 py-3 text-sm">
{roh.ocrText}
</pre>
</details>
)}
</CardContent>
</Card>
)}
</div>
)
}
/** Date -> "2026-03-12" fuer <input type="date">. */
function alsFeldwert(datum: Date | null): string {
return datum ? datum.toISOString().slice(0, 10) : ''
}
+1 -1
View File
@@ -124,7 +124,7 @@ export async function zieleDerAkte(betreuteId: string): Promise<Zielauswahl[]> {
ziel: 'buchung', ziel: 'buchung',
eintraege: buchungen.map((b) => ({ eintraege: buchungen.map((b) => ({
id: b.id, id: b.id,
titel: `${formatDatum(b.datum)} · ${b.richtung === 'AUSGANG' ? '−' : '+'}${formatCent( titel: `${formatDatum(b.datum)} · ${b.richtung === 'AUSGABE' ? '−' : '+'}${formatCent(
b.betragCent, b.betragCent,
)} · ${b.gegenstelle ?? 'ohne Gegenstelle'}`, )} · ${b.gegenstelle ?? 'ohne Gegenstelle'}`,
})), })),
+214 -206
View File
@@ -1,206 +1,214 @@
import 'server-only' import 'server-only'
import type * as React from 'react' import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer' import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib' import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp' import { typErkennen } from '@/lib/speicher/dateityp'
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
/**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen. /**
* * PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2. *
* * Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
* Zwei Wege fuehren hier zusammen: *
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte) * Zwei Wege fuehren hier zusammen:
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander * - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* (Belegmappe, Unterlagenpaket fuer das Gericht) * - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das * (Belegmappe, Unterlagenpaket fuer das Gericht)
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann. * 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 /** A4 in PDF-Punkten (72 dpi). */
export const A4_HOEHE = 841.89 export const A4_BREITE = 595.28
const RAND = 36 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<Buffer> { /** Rendert ein react-pdf-Dokument zu einem Buffer. */
// renderToBuffer erwartet ein <Document>-Element; die Typen von export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// @react-pdf/renderer sind hier enger als React.ReactElement. // Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0]) // Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
return Buffer.from(roh) // zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
} standardschriftenSicherstellen()
/** // renderToBuffer erwartet ein <Document>-Element; die Typen von
* Wandelt ein Bild in ein einseitiges PDF. // @react-pdf/renderer sind hier enger als React.ReactElement.
* const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die return Buffer.from(roh)
* 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<Buffer> { * Wandelt ein Bild in ein einseitiges PDF.
const typ = typErkennen(inhalt) *
if (!typ || !typ.istBild) { * JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.') * Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
} * jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/
let daten = inhalt export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png' const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) {
if (typ.endung !== 'jpg' && typ.endung !== 'png') { throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
const sharp = (await import('sharp')).default }
daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png' let daten = inhalt
} let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
const pdf = await PDFDocument.create() if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten) const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer()
const beschriftungshoehe = beschriftung ? 16 : 0 format = 'png'
const nutzbareBreite = A4_BREITE - 2 * RAND }
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
const pdf = await PDFDocument.create()
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde. const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE const beschriftungshoehe = beschriftung ? 16 : 0
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE const nutzbareBreite = A4_BREITE - 2 * RAND
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1) const querformat = bild.width > bild.height
const breite = bild.width * massstab const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const hoehe = bild.height * massstab const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const seite = pdf.addPage([seitenBreite, seitenHoehe]) const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
seite.drawImage(bild, {
x: (seitenBreite - breite) / 2, const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2, const breite = bild.width * massstab
width: breite, const hoehe = bild.height * massstab
height: hoehe,
}) const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, {
if (beschriftung) { x: (seitenBreite - breite) / 2,
const schrift = await pdf.embedFont(StandardFonts.Helvetica) y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), { width: breite,
x: RAND, height: hoehe,
y: RAND / 2, })
size: 8,
font: schrift, if (beschriftung) {
color: rgb(0.3, 0.3, 0.3), const schrift = await pdf.embedFont(StandardFonts.Helvetica)
}) seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
} x: RAND,
y: RAND / 2,
return Buffer.from(await pdf.save()) size: 8,
} font: schrift,
color: rgb(0.3, 0.3, 0.3),
/** })
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket). }
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/ return Buffer.from(await pdf.save())
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> { }
const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit') /**
ziel.setCreator('Betreuungs-Cockpit') * Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
for (const teil of teile) { */
const typ = typErkennen(teil) export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit')
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true }) ziel.setCreator('Betreuungs-Cockpit')
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite) for (const teil of teile) {
} const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften. const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
ziel.addPage([A4_BREITE, A4_HOEHE]) const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
} for (const seite of seiten) ziel.addPage(seite)
}
return Buffer.from(await ziel.save())
} if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
/** ziel.addPage([A4_BREITE, A4_HOEHE])
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der }
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/ return Buffer.from(await ziel.save())
export async function seitenzahl(pdf: Buffer): Promise<number> { }
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount() /**
} * Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
/** */
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen. export async function seitenzahl(pdf: Buffer): Promise<number> {
* const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest, return dokument.getPageCount()
* 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<Buffer> { * Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) *
const schrift = await dokument.embedFont(StandardFonts.Helvetica) * Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
const seiten = dokument.getPages() * auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
const gesamt = seiten.length * sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/
seiten.forEach((seite, i) => { export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const { width, height } = seite.getSize() const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const drehung = seite.getRotation().angle % 360 const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const zaehlung = `Seite ${i + 1} von ${gesamt}` const seiten = dokument.getPages()
const text = kuerzenFuerSchrift(fusszeile, 120) const gesamt = seiten.length
const groesse = 7.5
seiten.forEach((seite, i) => {
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls const { width, height } = seite.getSize()
// gedreht, sonst steht sie quer im Blatt. const drehung = seite.getRotation().angle % 360
if (drehung === 90 || drehung === 270) { const zaehlung = `Seite ${i + 1} von ${gesamt}`
seite.drawText(`${text} · ${zaehlung}`, { const text = kuerzenFuerSchrift(fusszeile, 120)
x: drehung === 90 ? 18 : width - 18, const groesse = 7.5
y: drehung === 90 ? 20 : height - 20,
size: groesse, // Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
font: schrift, // gedreht, sonst steht sie quer im Blatt.
color: rgb(0.35, 0.35, 0.35), if (drehung === 90 || drehung === 270) {
rotate: degrees(drehung), seite.drawText(`${text} · ${zaehlung}`, {
}) x: drehung === 90 ? 18 : width - 18,
return y: drehung === 90 ? 20 : height - 20,
} size: groesse,
font: schrift,
seite.drawText(text, { color: rgb(0.35, 0.35, 0.35),
x: 36, rotate: degrees(drehung),
y: 18, })
size: groesse, return
font: schrift, }
color: rgb(0.35, 0.35, 0.35),
}) seite.drawText(text, {
const breite = schrift.widthOfTextAtSize(zaehlung, groesse) x: 36,
seite.drawText(zaehlung, { y: 18,
x: width - 36 - breite, size: groesse,
y: 18, font: schrift,
size: groesse, color: rgb(0.35, 0.35, 0.35),
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,
return Buffer.from(await dokument.save()) size: groesse,
} font: schrift,
color: rgb(0.35, 0.35, 0.35),
/** })
* 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. return Buffer.from(await dokument.save())
*/ }
export function fuerStandardschrift(text: string): string {
return text /**
.replace(/[\u2013\u2014]/g, '-') * Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
.replace(/[\u2018\u2019\u201a]/g, "'") * ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
.replace(/[\u201c\u201d\u201e]/g, '"') * deshalb hier ersetzen statt riskieren.
.replace(/\u2026/g, '...') */
.replace(/\u20ac/g, 'EUR') export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
.replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das export function fuerStandardschrift(text: string): string {
// Speichern des PDFs scheitern zu lassen. return text
// eslint-disable-next-line no-control-regex .replace(/[\u2013\u2014]/g, '-')
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?') .replace(/[\u2018\u2019\u201a]/g, "'")
} .replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...')
function kuerzenFuerSchrift(text: string, maxZeichen: number): string { .replace(/\u20ac/g, 'EUR')
const sauber = fuerStandardschrift(text) .replace(/[\u00a0\u2007\u202f]/g, ' ')
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber // 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
}
+25
View File
@@ -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
}
}
+317
View File
@@ -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<string>(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<string, Standardschrift>()
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)
}
}
+607 -474
View File
File diff suppressed because it is too large Load Diff