From 6f213dca254a38114224715e372ced51f60b69a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:34 +0000 Subject: [PATCH] =?UTF-8?q?AB-PDF:=20erzeugeAuftragPdf=20zeichnet=20Briefk?= =?UTF-8?q?opf,=20Tabelle=20=C3=BCber=20mehrere=20Seiten,=20Belehrung=20un?= =?UTF-8?q?d=20Fu=C3=9F=20mit=20pdfkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 151 +++++++++++++++++++++++++++++++ test/auftraegePdf.test.js | 130 ++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 test/auftraegePdf.test.js diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 25c31a5..654bcff 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -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 +} diff --git a/test/auftraegePdf.test.js b/test/auftraegePdf.test.js new file mode 100644 index 0000000..54b8a2e --- /dev/null +++ b/test/auftraegePdf.test.js @@ -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) +})