AB-PDF: erzeugeAuftragPdf zeichnet Briefkopf, Tabelle über mehrere Seiten, Belehrung und Fuß mit pdfkit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:13:34 +00:00
1 parent 01406c82e5
commit 6f213dca25
2 files changed
+281

No files matched your search

+151
View File
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import * as fontkitModul from 'fontkit'
import PDFDocument from 'pdfkit'
import { euroDe } from '../finance/format.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { AuftragError } from './fehler.js'
@@ -299,3 +300,153 @@ export function paginiere(atome, bereiche = BEREICHE) {
}
return seiten
}
// ===== 4. Zeichnen mit pdfkit =====
/** Zeichnen mit pdfkit. Alle Texte stehen fertig in `daten`; Höhen und Seiten kommen aus dem Abschnitt Layout. */
const SCHRIFT_NAMEN = { regular: 'Figtree', semibold: 'Figtree-SemiBold' }
const RECHTS = SEITE.breite - SEITE.rechts
const SPALTEN_X = SPALTEN.reduce((liste, breite) => [...liste, liste[liste.length - 1] + breite], [SEITE.links]).slice(0, SPALTEN.length)
function setzeStil(doc, stil) {
return doc.font(SCHRIFT_NAMEN[stil.schrift]).fontSize(stil.groesse).fillColor(stil.farbe)
}
function schreibe(doc, text, x, y, stil, optionen = {}) {
setzeStil(doc, stil).text(text, x, y, { lineGap: stil.lineGap, ...optionen })
}
const linie = (doc, x1, y, x2, farbe = FARBEN.linie, breite = 0.5) => {
doc.save().lineWidth(breite).strokeColor(farbe).moveTo(x1, y).lineTo(x2, y).stroke().restore()
}
function zeichneBriefkopf(doc, daten, atom, y0, assets) {
const b = daten.beschriftung
const m = atom.masse
doc.image(assets.logo, SEITE.links, y0, { width: 34 })
schreibe(doc, 'WEBklar', SEITE.links + 43, y0 + 6, { schrift: 'semibold', groesse: 20, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.4, lineBreak: false })
const [name, ...rest] = daten.absender.zeilen
const blockX = RECHTS - 190
schreibe(doc, name, blockX, y0, { ...STIL.klein, schrift: 'semibold', farbe: FARBEN.tinte }, { width: 190, align: 'right' })
schreibe(doc, rest.join('\n'), blockX, y0 + 13, STIL.klein, { width: 190, align: 'right' })
schreibe(doc, daten.absender.rueckzeile, SEITE.links, y0 + MASSE.rueckY, { ...STIL.klein, groesse: 7 }, { width: MASSE.adresseBreite, lineBreak: false, underline: true })
schreibe(doc, daten.empfaenger.join('\n'), SEITE.links, y0 + MASSE.adresseY, STIL.adresse, { width: MASSE.adresseBreite })
m.infoZeilen.forEach(([label, wert], i) => {
const zeileY = y0 + MASSE.adresseY + i * MASSE.infoZeile
schreibe(doc, label, MASSE.infoX, zeileY + 1, STIL.klein, { width: 90, lineBreak: false })
schreibe(doc, wert, MASSE.infoX + 92, zeileY, { ...STIL.text, schrift: 'semibold' }, { width: RECHTS - MASSE.infoX - 92, align: 'right' })
})
schreibe(doc, daten.titel, SEITE.links, y0 + m.titelY, STIL.titel, { width: INHALT_BREITE })
schreibe(doc, `${b.projekt}: ${daten.projekt}`, SEITE.links, y0 + m.projektY, STIL.projekt, { width: INHALT_BREITE })
}
function zeichneAtom(doc, daten, atom, y, assets) {
switch (atom.typ) {
case 'briefkopf':
return zeichneBriefkopf(doc, daten, atom, y, assets)
case 'ueberschrift':
return schreibe(doc, atom.text, SEITE.links, y, atom.stil === 'titel' ? STIL.titel : STIL.ueberschrift, { width: INHALT_BREITE })
case 'absatz': {
const stil = STIL[atom.stil]
if (atom.kasten) {
doc.save().roundedRect(SEITE.links, y, INHALT_BREITE, atom.hoehe, 4).lineWidth(0.5).fillAndStroke(FARBEN.flaeche, FARBEN.linie).restore()
return schreibe(doc, atom.text, SEITE.links + MASSE.kastenRand, y + MASSE.kastenRand, stil, { width: INHALT_BREITE - 2 * MASSE.kastenRand })
}
return schreibe(doc, atom.text, SEITE.links, y, stil, { width: INHALT_BREITE })
}
case 'tabellenkopf': {
doc.save().rect(SEITE.links, y, INHALT_BREITE, atom.hoehe).fill(FARBEN.flaeche).restore()
atom.spalten.forEach((text, i) => {
const rechts = i >= 2
schreibe(doc, text, SPALTEN_X[i] + (i === 0 ? 0 : ZELLEN_RAND), y + 7, STIL.tabellenkopf, { width: SPALTEN[i] - (i === 0 ? ZELLEN_RAND : 2 * ZELLEN_RAND), align: rechts ? 'right' : 'left', lineBreak: false })
})
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS, FARBEN.linieDunkel, 0.75)
}
case 'zeile': {
const z = atom.zelle
const oben = y + ZELLEN_RAND
schreibe(doc, z.nr, SPALTEN_X[0], oben, STIL.text, { width: SPALTEN[0] - ZELLEN_RAND })
schreibe(doc, z.beschreibung, SPALTEN_X[1] + ZELLEN_RAND, oben, STIL.text, { width: SPALTEN[1] - 2 * ZELLEN_RAND })
schreibe(doc, z.menge, SPALTEN_X[2], oben, STIL.text, { width: SPALTEN[2] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.einzelpreis, SPALTEN_X[3], oben, STIL.text, { width: SPALTEN[3] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.summe, SPALTEN_X[4], oben, STIL.text, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right' })
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS)
}
case 'summe': {
linie(doc, SEITE.links, y, RECHTS, FARBEN.tinte, 0.75)
const labelBreite = SPALTEN_X[4] - SEITE.links - ZELLEN_RAND
schreibe(doc, atom.label, SEITE.links, y + 9, STIL.summe, { width: labelBreite, align: 'right', lineBreak: false })
return schreibe(doc, atom.betrag, SPALTEN_X[4], y + 9, STIL.summe, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right', lineBreak: false })
}
default:
throw new Error(`Unbekannter Baustein: ${atom.typ}`)
}
}
function zeichneFolgeKopf(doc, daten, assets) {
const y = SEITE.oben
doc.image(assets.logo, SEITE.links, y, { width: 16 })
schreibe(doc, 'WEBklar', SEITE.links + 22, y + 2, { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.2, lineBreak: false })
schreibe(doc, `${daten.titel} ${daten.nummer}`, RECHTS - 250, y + 4, STIL.klein, { width: 250, align: 'right', lineBreak: false })
linie(doc, SEITE.links, y + 24, RECHTS)
}
function zeichneFuss(doc, daten, nummer, gesamt) {
const b = daten.beschriftung
const y = SEITE.hoehe - MASSE.fussHoehe
linie(doc, SEITE.links, y, RECHTS)
const spalte = (x, breite, label, zeilen) => {
schreibe(doc, label, x, y + 8, STIL.fussLabel, { width: breite, lineBreak: false })
schreibe(doc, zeilen.join('\n'), x, y + 19, STIL.fuss, { width: breite, height: 46, ellipsis: true })
}
if (daten.fuss.bank.length) spalte(SEITE.links, 175, b.bank, daten.fuss.bank)
spalte(SEITE.links + 190, 225, b.kontakt, daten.fuss.kontakt)
schreibe(doc, `${b.seite} ${nummer} ${b.von} ${gesamt}`, RECHTS - 70, y + 8, STIL.fussLabel, { width: 70, align: 'right', lineBreak: false })
}
/** Zeichnet alle Seiten; die erste Seite legt pdfkit selbst an. */
function zeichneAuftrag(doc, daten, seiten, assets) {
seiten.forEach((seite, index) => {
if (index > 0) doc.addPage()
if (index > 0) zeichneFolgeKopf(doc, daten, assets)
for (const posten of seite.posten) zeichneAtom(doc, daten, posten.atom, posten.y, assets)
zeichneFuss(doc, daten, seite.nummer, seiten.length)
})
}
// ===== 5. Ablauf =====
/** Das AB-PDF (A4) als Buffer. */
export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date() } = {}) {
const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt }))
const assets = ladeSchriften()
const doc = new PDFDocument({
size: 'A4', margins: { top: 0, bottom: 0, left: 0, right: 0 }, lang: 'de-DE', displayTitle: true,
info: {
Title: `${daten.titel} ${daten.nummer}`, Author: 'WEBklar GbR', Subject: daten.projekt, Producer: 'WEBklar Kundenportal',
CreationDate: new Date(auftrag.gesendetAm || jetzt),
},
})
doc.registerFont('Figtree', assets.regular)
doc.registerFont('Figtree-SemiBold', assets.semibold)
const teile = []
doc.on('data', (teil) => teile.push(teil))
const fertig = new Promise((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(teile)))
doc.on('error', reject)
})
fertig.catch(() => {}) // scheitert das Zeichnen zuerst, soll das verworfene Versprechen nichts auslösen
try {
const messe = (text, stil, breite) => {
setzeStil(doc, stil)
return doc.heightOfString(text, { width: breite, lineGap: stil.lineGap })
}
const seiten = paginiere(baueAtome(daten, messe), BEREICHE)
zeichneAuftrag(doc, daten, seiten, assets)
doc.end()
} catch (fehler) {
doc.destroy()
throw fehler
}
return fertig
}
+130
View File
@@ -0,0 +1,130 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { alleTexte, beispielAuftrag, beispielSettings, leseSeiten, seitenZahl } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { auftragPdfDaten, erzeugeAuftragPdf, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
const { AB_TEXTE, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } = await import('../server/services/auftraege/texte.js')
const { aufwandText } = await import('../server/services/auftraege/auftragSicht.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { euroDe } = await import('../server/services/finance/format.js')
const JETZT = new Date('2026-10-06T10:00:00.000Z')
const cp = (...codes) => String.fromCodePoint(...codes)
const FRUEHBEGINN_SATZ = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.' // V7: AB_TEXTE.termine bei Privatpersonen
const optionen = (extra = {}) => ({ settings: beispielSettings, jetzt: JETZT, ...extra })
const ohneLeerraum = (text) => text.replace(/\s+/g, '')
const gesamtText = (pdf) => leseSeiten(pdf).flat().join('\n')
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
const aufwand = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
/** Alles, was in den Daten steht und nicht nur Beschriftung oder Schlüssel ist, muss im PDF-Text vorkommen. */
function pruefeAlleTexteImPdf(pdf, auftrag, extra = {}) {
const daten = bereinigeTexte(auftragPdfDaten(auftrag, optionen(extra)))
const imPdf = ohneLeerraum(gesamtText(pdf))
const erwartet = alleTexte({ ...daten, beschriftung: null })
assert.ok(erwartet.length > 20)
for (const text of erwartet) assert.ok(imPdf.includes(ohneLeerraum(text)), `fehlt im PDF: ${text.slice(0, 70)}`)
}
test('Festpreis-AB: gültiges A4-PDF, jede Angabe aus den Daten steht im Text', async () => {
const auftrag = beispielAuftrag()
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
assert.ok(Buffer.isBuffer(pdf))
assert.equal(pdf.subarray(0, 5).toString('latin1'), '%PDF-')
assert.equal(pdf.subarray(-6).toString('latin1'), '%%EOF\n')
assert.match(pdf.toString('latin1'), /\/MediaBox \[0 0 595\.28 841\.89\]/)
const n = seitenZahl(pdf)
assert.ok(n >= 1)
assert.equal(leseSeiten(pdf).length, n)
pruefeAlleTexteImPdf(pdf, auftrag)
const text = gesamtText(pdf)
for (const erwartet of ['AB-2026-0007', '06.10.2026', 'K-1042', 'Steuernummer 12/345/67890', euroDe(60000), euroDe(80000), `Seite 1 von ${n}`]) {
assert.ok(text.includes(erwartet), erwartet)
}
assert.equal(text.includes('ENTWURF'), false)
assert.equal(text.includes('\u{FFFD}'), false)
})
test('Aufwand-AB: Aufwandsregel statt Tabelle', async () => {
const auftrag = beispielAuftrag(aufwand)
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
pruefeAlleTexteImPdf(pdf, auftrag)
const text = gesamtText(pdf)
assert.ok(ohneLeerraum(text).includes(ohneLeerraum(aufwandText(auftrag))))
assert.equal(text.includes('Einzelpreis'), false)
assert.equal(text.includes('Gesamtpreis'), false)
})
test('Privatperson: Widerrufsbelehrung und Muster-Widerrufsformular auf eigenen Seiten nach der AB', async () => {
const auftrag = beispielAuftrag(privat)
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
const seiten = leseSeiten(pdf)
const texte = seiten.map((zeilen) => zeilen.join('\n'))
const index = seiten.findIndex((zeilen) => zeilen.includes(WIDERRUFSBELEHRUNG_PROJEKT.titel))
assert.ok(index >= 1, 'die Belehrung steht nicht auf Seite 1')
assert.equal(seiten[index][2], WIDERRUFSBELEHRUNG_PROJEKT.titel, 'sie beginnt oben, direkt unter dem Seitenkopf (WEBklar, Titel und Nummer)')
assert.ok(texte.slice(index).join('\n').includes(WIDERRUFSFORMULAR.titel))
assert.ok(ohneLeerraum(texte.join('')).includes(ohneLeerraum(FRUEHBEGINN_SATZ)), 'Satz zum Beginn nach Ablauf der Widerrufsfrist')
pruefeAlleTexteImPdf(pdf, auftrag)
texte.forEach((text, i) => assert.ok(text.includes(`Seite ${i + 1} von ${texte.length}`)))
const unternehmen = gesamtText(await erzeugeAuftragPdf(beispielAuftrag(), optionen()))
assert.equal(unternehmen.includes(WIDERRUFSFORMULAR.titel), false)
assert.equal(ohneLeerraum(unternehmen).includes(ohneLeerraum(FRUEHBEGINN_SATZ)), false, 'bei Unternehmen steht der Satz nicht im PDF')
})
test('Umlaute, ß, €, Minuszeichen, Gedankenstrich und Anführungszeichen kommen lesbar im PDF an', async () => {
const name = `Jörg Müßig${cp(0x2212)}Åström`
const titel = `Übergabe „Größe“ ${cp(0x2013)} 3${cp(0xa0)}€ ${cp(0x2212)}5${cp(0xa0)}€`
const auftrag = beispielAuftrag({ titel, kunde: { ...beispielAuftrag().kunde, name, firma: '' } })
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
const text = gesamtText(pdf)
assert.ok(text.includes(name), name)
assert.ok(ohneLeerraum(text).includes(ohneLeerraum(titel)), titel)
assert.equal(text.includes('\u{FFFD}'), false)
})
test('Zeichen ohne Glyph werden ersetzt statt als Kästchen gedruckt', async () => {
const auftrag = beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, firma: '', name: `${cp(0x411, 0x43e, 0x440)} Łukasz`, strasse: `Hauptstraße${cp(0x202f)}12` } })
const text = gesamtText(await erzeugeAuftragPdf(auftrag, optionen()))
assert.ok(text.includes('??? Łukasz'))
assert.ok(text.includes('Hauptstraße 12'))
assert.equal(text.includes('\u{FFFD}'), false)
})
test('20 Positionen mit 200 Zeichen ohne Leerzeichen: mehrere Seiten, Tabellenkopf auf jeder Seite mit Positionen, nichts geht verloren', async () => {
const baustein = 'ÄÖÜäöü߀' + cp(0x2212)
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `P${i + 1}${baustein.repeat(30)}`.slice(0, 200), menge: i + 1, einzelpreisCents: 1000 + i }))
const summeCents = positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
const auftrag = beispielAuftrag({ positionen, summeCents, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
const n = seitenZahl(pdf)
const seiten = leseSeiten(pdf).map((zeilen) => zeilen.join('\n'))
assert.ok(n >= 2, `Seiten: ${n}`)
assert.equal(seiten.length, n)
const alles = ohneLeerraum(seiten.join('\n'))
for (const p of positionen) assert.ok(alles.includes(ohneLeerraum(p.beschreibung)), p.beschreibung.slice(0, 12))
assert.ok(alles.includes(ohneLeerraum(euroDe(summeCents))), 'Gesamtpreis')
seiten.forEach((text, i) => {
if (/P\d+/.test(text)) assert.ok(text.includes('Beschreibung') && text.includes('Einzelpreis'), `Seite ${i + 1} ohne Tabellenkopf`)
assert.ok(text.includes(`Seite ${i + 1} von ${n}`))
assert.equal(text.includes('\u{FFFD}'), false)
})
assert.ok(seiten.find((text) => text.includes('P20')).includes('Gesamtpreis'), 'Summe steht auf der Seite der letzten Position')
})
test('mehrere PDFs gleichzeitig: Schriften sind geteilt, nichts vermischt sich', async () => {
const nummern = [1, 2, 3, 4, 5].map((i) => `AB-2026-000${i}`)
const pdfs = await Promise.all(nummern.map((nummer) => erzeugeAuftragPdf(beispielAuftrag({ nummer }), optionen())))
pdfs.forEach((pdf, i) => {
const text = gesamtText(pdf)
assert.ok(text.includes(nummern[i]))
assert.equal(nummern.filter((n) => text.includes(n)).length, 1)
})
})
test('unvollständige AB: AuftragError 409 statt halbem PDF', async () => {
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ titel: '' }), optionen()), (e) => e instanceof AuftragError && e.status === 409)
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ kunde: null }), optionen({ vorschau: true })), (e) => e instanceof AuftragError && e.status === 409)
})