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:
+214
-206
@@ -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<Buffer> {
|
||||
// renderToBuffer erwartet ein <Document>-Element; die Typen von
|
||||
// @react-pdf/renderer sind hier enger als React.ReactElement.
|
||||
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<number> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
// 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 <Document>-Element; die Typen von
|
||||
// @react-pdf/renderer sind hier enger als React.ReactElement.
|
||||
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<number> {
|
||||
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<Buffer> {
|
||||
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
|
||||
}
|
||||
|
||||
Vendored
+25
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user