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 }