Ausschliesslich Zeilenenden. Nachgewiesen mit "git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich kein Zeichen Inhalt geaendert. Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit echten Aenderungen zu verstecken, macht die Historie an dieser Stelle unlesbar. So laesst er sich beim Durchsehen ueberspringen. Refs #26
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
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
|
|
}
|