Merge auftraege/l4-ab-pdf: Auftragsbestätigung PDF (Strang L4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:20:45 +00:00
commit bd12892ed2
12 files changed
+1282

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
Figtree, statische TrueType-Schnitte Regular (400) und SemiBold (600) für das PDF der Auftragsbestätigung.
Lizenz: SIL Open Font License 1.1, Text in Figtree-OFL.txt.
Quelle: npm-Paket @expo-google-fonts/figtree@0.4.1 (400Regular/Figtree_400Regular.ttf, 600SemiBold/Figtree_600SemiBold.ttf, LICENSE_FONT als Figtree-OFL.txt), Stand 2026-10-06.
SHA-256 Figtree-Regular.ttf: 4e2e9876c4a908c2cbe410ceb648e4724464a84492bd49cb2d6e7b0d3f769a66
SHA-256 Figtree-SemiBold.ttf: 1b76aae525b51758fc0c9c5096f33d90831657d3ad2fe3c34fb7e666d76f8960
+475
View File
@@ -0,0 +1,475 @@
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'
import { AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AB_TEXTE, ABSENDER, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from './texte.js'
import { aufwandText, zahlungsplanText } from './auftragSicht.js'
/**
* PDF der Auftragsbestätigung (A4, pdfkit): Schriften und Zeichenabdeckung, Daten (alle Texte fertig formatiert),
* Seitenaufteilung (rein) und Zeichnen. Aufbau der Datei: 1 Schriften, 2 Daten, 3 Layout, 4 Zeichnen, 5 erzeugeAuftragPdf.
*/
// ===== 1. Schriften, Logo, Zeichenabdeckung =====
const fontkit = fontkitModul.default ?? fontkitModul
const SCHRIFT_VERZEICHNIS = new URL('../../assets/fonts/', import.meta.url)
const LOGO_DATEI = new URL('../../../public/webklar-logo.png', import.meta.url)
const SCHRIFT_DATEIEN = Object.freeze({ regular: 'Figtree-Regular.ttf', semibold: 'Figtree-SemiBold.ttf' })
let geladen = null
/** { regular: Buffer, semibold: Buffer, logo: Buffer, regularFont, semiboldFont } – bei jedem Aufruf dasselbe Objekt. */
export function ladeSchriften() {
if (!geladen) {
const regular = readFileSync(new URL(SCHRIFT_DATEIEN.regular, SCHRIFT_VERZEICHNIS))
const semibold = readFileSync(new URL(SCHRIFT_DATEIEN.semibold, SCHRIFT_VERZEICHNIS))
geladen = Object.freeze({
regular,
semibold,
logo: readFileSync(LOGO_DATEI),
regularFont: fontkit.create(regular),
semiboldFont: fontkit.create(semibold),
})
}
return geladen
}
/** Hat der Codepunkt ein Glyph in Regular UND SemiBold? */
export function hatGlyph(codePoint) {
const { regularFont, semiboldFont } = ladeSchriften()
return regularFont.hasGlyphForCodePoint(codePoint) && semiboldFont.hasGlyphForCodePoint(codePoint)
}
const ERSATZ = new Map([
[0x2010, '-'], [0x2011, '-'], [0x2012, '-'], [0x2013, '-'], [0x2014, '-'], [0x2015, '-'], [0x2212, '-'],
[0x2018, "'"], [0x2019, "'"], [0x201a, "'"], [0x2032, "'"],
[0x201c, '"'], [0x201d, '"'], [0x201e, '"'], [0x201f, '"'], [0x2033, '"'],
[0x2026, '...'], [0x20ac, 'EUR'], [0x2022, '-'], [0x00b7, '-'], [0x00d7, 'x'], [0x2192, '->'],
])
// Steuerzeichen (außer \n), weiche Trennung, Zero-Width-Zeichen, Variantenselektoren, BOM
const ENTFERNEN = /[\u{0}-\u{8}\u{B}-\u{1F}\u{7F}-\u{9F}\u{AD}\u{200B}-\u{200D}\u{2060}\u{FE00}-\u{FE0F}\u{FEFF}]/gu
/** Ersetzt Zeichen ohne Glyph, damit nie ein leeres Kästchen im PDF steht. */
export function bereinigeText(text, hatGlyphFn = hatGlyph) {
const roh = String(text ?? '')
.normalize('NFC')
.replace(/\r\n?|[\u{2028}\u{2029}]/gu, '\n')
.replace(/\t/g, ' ')
.replace(ENTFERNEN, '')
let aus = ''
for (const zeichen of roh) {
const cp = zeichen.codePointAt(0)
if (zeichen === '\n' || hatGlyphFn(cp)) aus += zeichen
else if (ERSATZ.has(cp)) aus += ERSATZ.get(cp)
else if (/\p{Zs}/u.test(zeichen)) aus += ' '
else if (/\p{M}/u.test(zeichen)) continue
else {
const basis = zeichen.normalize('NFD').codePointAt(0)
aus += basis !== cp && hatGlyphFn(basis) ? String.fromCodePoint(basis) : '?'
}
}
return aus
}
/** bereinigeText für jede Zeichenkette in beliebig verschachtelten Objekten und Listen. */
export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) {
if (typeof wert === 'string') return bereinigeText(wert, hatGlyphFn)
if (Array.isArray(wert)) return wert.map((e) => bereinigeTexte(e, hatGlyphFn))
if (wert && typeof wert === 'object') return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, bereinigeTexte(v, hatGlyphFn)]))
return wert
}
// ===== 2. Daten =====
export const PDF_BESCHRIFTUNG = Object.freeze({
nummer: 'Nummer', datum: 'Datum', kundennummer: 'Kundennummer', projekt: 'Projekt',
leistung: 'Leistungsumfang', zahlung: 'Zahlung', termine: 'Termine', mitwirkung: 'Mitwirkung und Abnahme',
spalten: Object.freeze(['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe']), gesamt: 'Gesamtpreis',
steuernummer: 'Steuernummer', telefon: 'Telefon', bank: 'Bankverbindung', kontakt: 'Kontakt', seite: 'Seite', von: 'von',
entwurf: 'ENTWURF', entwurfHinweis: 'Entwurf – Vorschau, noch keine gültige Auftragsbestätigung',
})
const fehlt = (was) => new AuftragError(409, `Entwurf unvollständig: ${was}`)
const zeilen = (text) => String(text ?? '').split('\n').map((z) => z.trim()).filter(Boolean)
const tag = (iso) => formatDe(berlinDate(new Date(iso)))
function pruefe(auftrag, vorschau) {
if (!auftrag?.kunde || typeof auftrag.kunde !== 'object') throw fehlt('Kundendaten fehlen.')
if (!vorschau && !auftrag.nummer) throw fehlt('Nummer fehlt.')
if (!String(auftrag.titel ?? '').trim()) throw fehlt('Titel fehlt.')
if (!/^\d{4}-\d{2}-\d{2}$/.test(auftrag.auftragVom ?? '')) throw fehlt('Datum der Absprache fehlt.')
if (!Object.hasOwn(AUFTRAG_WEGE, auftrag.auftragWeg)) throw fehlt('Weg der Absprache fehlt.')
if (!AUFTRAG_ARTEN.includes(auftrag.art)) throw fehlt('Art des Auftrags fehlt.')
if (!ZAHLUNGSPLAN_ARTEN.includes(auftrag.zahlungsplan?.art)) throw fehlt('Zahlungsplan fehlt.')
if (auftrag.art === 'festpreis') {
if (!Array.isArray(auftrag.positionen) || auftrag.positionen.length === 0) throw fehlt('Positionen fehlen.')
const summe = auftrag.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
if (summe !== auftrag.summeCents) throw new AuftragError(409, 'Die Summe der Positionen stimmt nicht mit der Auftragssumme überein.')
} else if (!Number.isSafeInteger(auftrag.stundensatzCents) || auftrag.stundensatzCents <= 0) {
throw fehlt('Stundensatz fehlt.')
}
}
function empfaengerZeilen(kunde) {
const land = String(kunde.land ?? '').trim()
const ausland = land && !/^(deutschland|germany|de)$/i.test(land)
return [kunde.firma, kunde.name, kunde.strasse, [kunde.plz, kunde.ort].filter(Boolean).join(' '), ausland ? land : '']
.map((z) => String(z ?? '').trim())
.filter((z, i, alle) => z && alle.indexOf(z) === i)
}
/** Alle Texte und Zahlen des AB-PDFs, fertig formatiert (reine Funktion). */
export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetzt = new Date(), ersetzt = null } = {}) {
pruefe(auftrag, vorschau)
const b = PDF_BESCHRIFTUNG
const steuernummer = String(settings.steuernummer ?? '').trim()
const festpreis = auftrag.art === 'festpreis'
const privat = auftrag.kunde.kundenart === 'privat'
const termine = AB_TEXTE.termine({ start: auftrag.startAm ? formatDe(auftrag.startAm) : null, fertig: auftrag.fertigBis ? formatDe(auftrag.fertigBis) : null, privat })
const abschnitte = [
{ id: 'zahlung', ueberschrift: b.zahlung, text: zahlungsplanText(auftrag) },
...(termine ? [{ id: 'termine', ueberschrift: b.termine, text: termine }] : []),
{ id: 'mitwirkung', ueberschrift: b.mitwirkung, text: AB_TEXTE.mitwirkung },
]
return {
vorschau,
beschriftung: b,
titel: AB_TEXTE.titel,
nummer: auftrag.nummer || 'Entwurf',
datum: tag(auftrag.gesendetAm || jetzt),
kundennummer: String(auftrag.kunde.kundennummer ?? ''),
projekt: auftrag.titel.trim(),
absender: {
zeilen: [ABSENDER.name, ABSENDER.strasse, ABSENDER.plzOrt, `${b.telefon} ${ABSENDER.telefon}`, ABSENDER.email, ABSENDER.web, ...(steuernummer ? [`${b.steuernummer} ${steuernummer}`] : [])],
rueckzeile: `${ABSENDER.name} · ${ABSENDER.strasse} · ${ABSENDER.plzOrt}`,
},
empfaenger: empfaengerZeilen(auftrag.kunde),
einleitung: AB_TEXTE.einleitung({ datum: formatDe(auftrag.auftragVom), weg: AUFTRAG_WEGE[auftrag.auftragWeg] }),
leistung: festpreis
? {
art: 'festpreis',
ueberschrift: b.leistung,
positionen: auftrag.positionen.map((p, i) => ({
nr: String(i + 1), beschreibung: p.beschreibung.trim(), menge: String(p.menge),
einzelpreis: euroDe(p.einzelpreisCents), summe: euroDe(p.menge * p.einzelpreisCents),
})),
gesamt: { label: b.gesamt, betrag: euroDe(auftrag.summeCents) },
aufwandText: null,
}
: { art: 'aufwand', ueberschrift: b.leistung, positionen: [], gesamt: null, aufwandText: aufwandText(auftrag) },
kleinunternehmer: AB_TEXTE.kleinunternehmer,
abschnitte,
agb: AB_TEXTE.agb({ fassung: formatDe(auftrag.agbFassung || AGB_FASSUNG) }),
ersetzt: ersetzt ? AB_TEXTE.ersetzt({ nummer: ersetzt.nummer, datum: formatDe(ersetzt.datum) }) : null,
gruss: AB_TEXTE.gruss,
widerruf: privat ? { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR } : null,
fuss: {
bank: zeilen(settings.bankDetailsText),
kontakt: [`${ABSENDER.name} · vertreten durch ${ABSENDER.vertretenDurch}`, `${b.telefon} ${ABSENDER.telefon} · ${ABSENDER.email} · ${ABSENDER.web}`],
},
}
}
// ===== 3. Layout (rein, ohne pdfkit): Maße, Bausteine, Seitenaufteilung =====
export const SEITE = Object.freeze({ breite: 595.28, hoehe: 841.89, links: 56.7, rechts: 56.7, oben: 48, unten: 96 })
export const INHALT_BREITE = SEITE.breite - SEITE.links - SEITE.rechts // 481.88
const FARBEN = Object.freeze({ tinte: '#111111', gedaempft: '#6b6964', linie: '#e8e6e0', linieDunkel: '#c9c6bd', flaeche: '#f7f6f3', entwurf: '#b42318' })
export const SPALTEN = Object.freeze([28, 237.88, 44, 86, 86]) // Pos., Beschreibung, Menge, Einzelpreis, Summe
const ZELLEN_RAND = 6
const STIL = Object.freeze({
titel: { schrift: 'semibold', groesse: 22, lineGap: 0, farbe: FARBEN.tinte },
projekt: { schrift: 'regular', groesse: 11, lineGap: 2, farbe: FARBEN.tinte },
adresse: { schrift: 'regular', groesse: 10, lineGap: 2.5, farbe: FARBEN.tinte },
ueberschrift: { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte },
text: { schrift: 'regular', groesse: 9.5, lineGap: 2.5, farbe: FARBEN.tinte },
klein: { schrift: 'regular', groesse: 8.5, lineGap: 2, farbe: FARBEN.gedaempft },
tabellenkopf: { schrift: 'semibold', groesse: 8.5, lineGap: 0, farbe: FARBEN.gedaempft },
summe: { schrift: 'semibold', groesse: 10.5, lineGap: 0, farbe: FARBEN.tinte },
fussLabel: { schrift: 'semibold', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
fuss: { schrift: 'regular', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
})
const MASSE = Object.freeze({
kopfHoehe: 22, summeHoehe: 30, kastenRand: 8, adresseBreite: 250, infoX: 340, infoZeile: 15,
rueckY: 80, adresseY: 94, // Rücksendezeile und Anschriftfeld relativ zur Oberkante des Briefkopfs (DIN 5008, Form B)
fussHoehe: 82, folgeKopf: 40,
})
const SICHERHEIT = 1
export const BEREICHE = Object.freeze({
erste: { oben: SEITE.oben, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
folge: { oben: SEITE.oben + MASSE.folgeKopf, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
})
const atom = (id, typ, hoehe, extra = {}) => ({ id, typ, hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra })
/** Alle Bausteine der AB in Zeichenreihenfolge. messe(text, stil, breite) liefert die Texthöhe in pt. */
export function baueAtome(daten, messe) {
const b = daten.beschriftung
const B = INHALT_BREITE
const atome = []
const absatz = (id, text, extra = {}, stil = STIL.text) => atome.push(atom(id, 'absatz', messe(text, stil, B), { text, stil: stil === STIL.klein ? 'klein' : 'text', abstandDavor: 6, ...extra }))
const ueberschrift = (id, text) => atome.push(atom(id, 'ueberschrift', messe(text, STIL.ueberschrift, B) + 5, { text, abstandDavor: 14, haengtAnNaechstem: true }))
// Briefkopf (nur Seite 1): Logo, Absender, Anschrift, Angaben, Titel
const infoZeilen = [[b.nummer, daten.nummer], [b.datum, daten.datum], ...(daten.kundennummer ? [[b.kundennummer, daten.kundennummer]] : [])]
const adresseHoehe = messe(daten.empfaenger.join('\n'), STIL.adresse, MASSE.adresseBreite)
const blockEnde = MASSE.adresseY + Math.max(adresseHoehe, infoZeilen.length * MASSE.infoZeile)
const titelY = blockEnde + 22
const projektY = titelY + STIL.titel.groesse * 1.2 + 6
const projektHoehe = messe(`${b.projekt}: ${daten.projekt}`, STIL.projekt, B)
atome.push(atom('briefkopf', 'briefkopf', projektY + projektHoehe + 4, { haengtAnNaechstem: true, masse: { titelY, projektY, projektHoehe, infoZeilen } }))
absatz('einleitung', daten.einleitung, { abstandDavor: 8 })
const l = daten.leistung
ueberschrift('leistung', l.ueberschrift)
if (l.art === 'festpreis') {
const kopf = atom('tabellenkopf', 'tabellenkopf', MASSE.kopfHoehe, { haengtAnNaechstem: true, spalten: b.spalten })
atome.push(kopf)
l.positionen.forEach((p, i) => {
const text = messe(p.beschreibung, STIL.text, SPALTEN[1] - 2 * ZELLEN_RAND)
const eineZeile = messe('X', STIL.text, SPALTEN[1])
atome.push(atom(`zeile-${i + 1}`, 'zeile', Math.max(text, eineZeile) + 2 * ZELLEN_RAND, { zelle: p, kopf, haengtAnNaechstem: i === l.positionen.length - 1 }))
})
atome.push(atom('summe', 'summe', MASSE.summeHoehe, { label: l.gesamt.label, betrag: l.gesamt.betrag, haengtAnNaechstem: true }))
} else {
atome.push(atom('aufwand', 'absatz', messe(l.aufwandText, STIL.text, B - 2 * MASSE.kastenRand) + 2 * MASSE.kastenRand, { text: l.aufwandText, stil: 'text', kasten: true, haengtAnNaechstem: true }))
}
absatz('kleinunternehmer', daten.kleinunternehmer, { abstandDavor: 8 }, STIL.klein)
for (const a of daten.abschnitte) {
ueberschrift(`${a.id}-kopf`, a.ueberschrift)
absatz(a.id, a.text, { abstandDavor: 0 })
}
absatz('agb', daten.agb, { abstandDavor: 12 })
if (daten.ersetzt) absatz('ersetzt', daten.ersetzt, { abstandDavor: 8 })
absatz('gruss', daten.gruss, { abstandDavor: 14 })
if (daten.widerruf) {
const { belehrung, formular } = daten.widerruf
atome.push(atom('widerruf-titel', 'ueberschrift', messe(belehrung.titel, STIL.titel, B) + 8, { text: belehrung.titel, stil: 'titel', umbruchDavor: true, haengtAnNaechstem: true }))
belehrung.abschnitte.forEach((a, i) => {
ueberschrift(`widerruf-${i + 1}-kopf`, a.ueberschrift)
a.absaetze.forEach((t, k) => absatz(`widerruf-${i + 1}-${k + 1}`, t, { abstandDavor: k === 0 ? 0 : 5 }))
})
atome.push(atom('formular-titel', 'ueberschrift', messe(formular.titel, STIL.ueberschrift, B) + 5, { text: formular.titel, abstandDavor: 20, haengtAnNaechstem: true }))
absatz('formular-einleitung', formular.einleitung, { abstandDavor: 0, haengtAnNaechstem: true }, STIL.klein)
formular.zeilen.forEach((t, i) => absatz(`formular-${i + 1}`, t, { abstandDavor: 8, haengtAnNaechstem: i < formular.zeilen.length - 1 }))
}
return atome
}
/** Teilt Bausteine auf Seiten auf (Ketten bleiben zusammen, Tabellenkopf wiederholt sich). */
export function paginiere(atome, bereiche = BEREICHE) {
const seiten = []
const folgeHoehe = bereiche.folge.unten - bereiche.folge.oben
let seite
let y
const neueSeite = () => {
const bereich = seiten.length === 0 ? bereiche.erste : bereiche.folge
seite = { nummer: seiten.length + 1, posten: [], unten: bereich.unten }
seiten.push(seite)
y = bereich.oben
}
const frisch = () => seite.posten.every((p) => p.wiederholt)
const abstand = (a) => (frisch() ? 0 : a.abstandDavor)
neueSeite()
for (let i = 0; i < atome.length; ) {
let j = i
while (j < atome.length - 1 && atome[j].haengtAnNaechstem) j += 1
const kette = atome.slice(i, j + 1)
i = j + 1
if (kette[0].umbruchDavor && !frisch()) neueSeite()
const bedarf = kette.reduce((summe, a, k) => summe + (k === 0 ? 0 : a.abstandDavor) + a.hoehe, 0)
const kopfHoehe = kette[0].kopf ? kette[0].kopf.hoehe : 0
if (!frisch() && y + abstand(kette[0]) + bedarf > seite.unten && bedarf + kopfHoehe <= folgeHoehe) neueSeite()
for (const a of kette) {
if (!frisch() && y + abstand(a) + a.hoehe > seite.unten) neueSeite()
if (seite.nummer > 1 && a.kopf && seite.posten.length === 0) {
seite.posten.push({ atom: a.kopf, y, wiederholt: true })
y += a.kopf.hoehe
}
y += abstand(a)
seite.posten.push({ atom: a, y, wiederholt: false })
y += a.hoehe
}
}
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 })
}
function zeichneWasserzeichen(doc, daten) {
const stil = { schrift: 'semibold', groesse: 110, lineGap: 0, farbe: FARBEN.entwurf }
setzeStil(doc, stil)
const breite = doc.widthOfString(daten.beschriftung.entwurf)
const mitteX = SEITE.breite / 2
const mitteY = SEITE.hoehe / 2
doc.save()
doc.fillOpacity(0.07)
doc.rotate(-35, { origin: [mitteX, mitteY] })
doc.text(daten.beschriftung.entwurf, mitteX - breite / 2, mitteY - (stil.groesse * 1.2) / 2, { lineBreak: false })
doc.restore()
schreibe(doc, daten.beschriftung.entwurfHinweis, SEITE.links, 22, { schrift: 'semibold', groesse: 8, lineGap: 0, farbe: FARBEN.entwurf }, { width: INHALT_BREITE, align: 'center', 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 (daten.vorschau) zeichneWasserzeichen(doc, daten)
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 =====
async function ladeErsetzte(id) {
const { getAuftrag } = await import('./auftragStore.js')
const alt = await getAuftrag(id)
if (!alt?.nummer || !alt.gesendetAm) throw new AuftragError(409, 'Die ersetzte Auftragsbestätigung wurde nicht gefunden.')
return { nummer: alt.nummer, datum: berlinDate(new Date(alt.gesendetAm)) }
}
/** Das AB-PDF (A4) als Buffer. */
export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date(), ersetzt } = {}) {
const alt = ersetzt !== undefined ? ersetzt : auftrag.ersetztId ? await ladeErsetzte(auftrag.ersetztId) : null
const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt, ersetzt: alt }))
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
}
+147
View File
@@ -0,0 +1,147 @@
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)
})
test('Vorschau: ENTWURF und Hinweis auf jeder Seite, Nummer „Entwurf“', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ nummer: '', gesendetAm: null }), optionen({ vorschau: true }))
const seiten = leseSeiten(pdf)
assert.ok(seiten.length >= 1)
for (const zeilen of seiten) {
const text = zeilen.join('\n')
assert.ok(text.includes('ENTWURF'))
assert.ok(text.includes('Entwurf – Vorschau, noch keine gültige Auftragsbestätigung'))
}
assert.ok(gesamtText(pdf).includes('Entwurf'))
})
test('ersetzte AB: Hinweis mit Nummer und Datum der alten AB im PDF', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag(), optionen({ ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }))
assert.ok(ohneLeerraum(gesamtText(pdf)).includes(ohneLeerraum(AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))))
})
+172
View File
@@ -0,0 +1,172 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { auftragPdfDaten, PDF_BESCHRIFTUNG, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
const { AB_TEXTE, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } = await import('../server/services/auftraege/texte.js')
const { zahlungsplanText, 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 NBSP = String.fromCodePoint(0xa0)
const FRUEHBEGINN_SATZ = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.' // V7: AB_TEXTE.termine bei Privatpersonen
const daten = (extra = {}, optionen = {}) => auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, jetzt: JETZT, ...optionen })
const aufwandAuftrag = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
test('euroDe liefert Beträge mit geschütztem Leerzeichen vor dem €-Zeichen (Grundlage aller Betragsvergleiche hier)', () => {
assert.equal(euroDe(60000), `600,00${NBSP}€`)
})
test('Festpreis: Kopf, Anschrift, Angaben und Positionen sind fertig formatiert', () => {
const d = daten()
assert.equal(d.vorschau, false)
assert.equal(d.titel, AB_TEXTE.titel)
assert.equal(d.nummer, 'AB-2026-0007')
assert.equal(d.datum, '06.10.2026')
assert.equal(d.kundennummer, 'K-1042')
assert.equal(d.projekt, 'Neue Website Müller Bäckerei')
assert.deepEqual(d.absender.zeilen, [
'WEBklar GbR', 'Am Schwimmbad 10', '67722 Winnweiler', 'Telefon 0176 23726355', 'support@webklar.com', 'webklar.com', 'Steuernummer 12/345/67890',
])
assert.equal(d.absender.rueckzeile, 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler')
assert.deepEqual(d.empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
assert.equal(d.einleitung, AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }))
assert.ok(d.einleitung.includes('05.10.2026') && d.einleitung.includes('telefonisch'))
assert.equal(d.leistung.art, 'festpreis')
assert.equal(d.leistung.ueberschrift, 'Leistungsumfang')
assert.deepEqual(d.leistung.positionen, [
{ nr: '1', beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: '1', einzelpreis: euroDe(60000), summe: euroDe(60000) },
{ nr: '2', beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: '2', einzelpreis: euroDe(10000), summe: euroDe(20000) },
])
assert.deepEqual(d.leistung.gesamt, { label: 'Gesamtpreis', betrag: euroDe(80000) })
assert.equal(d.leistung.aufwandText, null)
assert.equal(d.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
assert.deepEqual(PDF_BESCHRIFTUNG.spalten, ['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe'])
})
test('Datum der AB ist der Berliner Tag von gesendetAm, ohne gesendetAm der heutige Berliner Tag', () => {
assert.equal(daten({ gesendetAm: '2026-10-06T22:30:00.000Z' }).datum, '07.10.2026')
assert.equal(daten({ gesendetAm: '2026-10-06T21:30:00+00:00' }).datum, '06.10.2026')
assert.equal(daten({ gesendetAm: null }, { jetzt: new Date('2026-12-31T23:30:00.000Z') }).datum, '01.01.2027')
})
test('Aufwand: Aufwandsregel statt Positionen, keine Gesamtsumme', () => {
const auftrag = beispielAuftrag(aufwandAuftrag)
const d = auftragPdfDaten(auftrag, { settings: beispielSettings, jetzt: JETZT })
assert.equal(d.leistung.art, 'aufwand')
assert.deepEqual(d.leistung.positionen, [])
assert.equal(d.leistung.gesamt, null)
assert.equal(d.leistung.aufwandText, aufwandText(auftrag))
assert.ok(d.leistung.aufwandText.includes(euroDe(10000)))
assert.equal(d.kleinunternehmer, AB_TEXTE.kleinunternehmer)
})
test('Vorschau: Nummer „Entwurf“, heutiges Datum, Kennzeichnung; ohne Vorschau braucht die AB eine Nummer', () => {
const entwurf = { nummer: '', gesendetAm: null }
const d = daten(entwurf, { vorschau: true })
assert.equal(d.vorschau, true)
assert.equal(d.nummer, 'Entwurf')
assert.equal(d.datum, '06.10.2026')
assert.equal(d.beschriftung.entwurf, 'ENTWURF')
assert.throws(() => daten(entwurf), (e) => e instanceof AuftragError && e.status === 409 && /Nummer/.test(e.message))
})
test('Anschrift: Firma und Name nur einmal, Land nur außerhalb Deutschlands, leere Teile entfallen', () => {
const kunde = (extra) => ({ ...beispielAuftrag().kunde, ...extra })
assert.deepEqual(daten({ kunde: kunde({ firma: 'Anna Müller' }) }).empfaenger, ['Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
assert.deepEqual(daten({ kunde: kunde({ land: 'Österreich', plz: '1010', ort: 'Wien' }) }).empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '1010 Wien', 'Österreich'])
assert.deepEqual(daten({ kunde: kunde({ firma: '', land: '', strasse: '' }) }).empfaenger, ['Anna Müller', '67722 Winnweiler'])
})
test('Fehlende Pflichtangaben und falsche Summe: AuftragError 409 mit Klartext statt kaputtem PDF', () => {
const faelle = [
[{ kunde: null }, /Kundendaten/],
[{ titel: ' ' }, /Titel/],
[{ auftragVom: '' }, /Datum der Absprache/],
[{ auftragWeg: 'brieftaube' }, /Weg der Absprache/],
[{ art: 'pauschal' }, /Art des Auftrags/],
[{ zahlungsplan: null }, /Zahlungsplan/],
[{ positionen: [], summeCents: 0 }, /Positionen/],
[{ summeCents: 79999 }, /Summe der Positionen/],
[{ ...aufwandAuftrag, stundensatzCents: null }, /Stundensatz/],
]
for (const [extra, muster] of faelle) {
assert.throws(() => daten(extra, { vorschau: true }), (e) => e instanceof AuftragError && e.status === 409 && muster.test(e.message), JSON.stringify(extra))
}
})
test('Zahlungsplan Anzahlung nennt Anzahlung und Rest mit Beträgen und das Zahlungsziel', () => {
const auftrag = beispielAuftrag()
const text = daten().abschnitte.find((a) => a.id === 'zahlung').text
assert.equal(text, zahlungsplanText(auftrag))
assert.equal(text.split(euroDe(40000)).length - 1, 2)
assert.ok(text.includes('50') && text.includes('14'))
const nachAufwand = beispielAuftrag(aufwandAuftrag)
assert.equal(auftragPdfDaten(nachAufwand, { settings: beispielSettings, jetzt: JETZT }).abschnitte[0].text, zahlungsplanText(nachAufwand))
const dreissig = beispielAuftrag({ zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 30 })
const text30 = auftragPdfDaten(dreissig, { settings: beispielSettings, jetzt: JETZT }).abschnitte[0].text
assert.ok(text30.includes(euroDe(24000)) && text30.includes(euroDe(56000)) && text30.includes('30'))
})
test('Abschnitte: Zahlung, Termine (nur mit Datum), Mitwirkung und Abnahme; AGB-Satz mit Fassung', () => {
const d = daten()
assert.deepEqual(d.abschnitte.map((a) => [a.id, a.ueberschrift]), [['zahlung', 'Zahlung'], ['termine', 'Termine'], ['mitwirkung', 'Mitwirkung und Abnahme']])
assert.equal(d.abschnitte[1].text, AB_TEXTE.termine({ start: '12.10.2026', fertig: '15.11.2026', privat: false }))
assert.equal(d.abschnitte[2].text, AB_TEXTE.mitwirkung)
assert.equal(d.agb, AB_TEXTE.agb({ fassung: '30.09.2026' }))
assert.equal(daten({ startAm: '', fertigBis: '' }).abschnitte.some((a) => a.id === 'termine'), false)
assert.equal(daten({ agbFassung: '' }).agb, AB_TEXTE.agb({ fassung: '30.09.2026' }), 'ohne agbFassung gilt AGB_FASSUNG')
assert.equal(daten().gruss, AB_TEXTE.gruss)
})
test('Termine: Privatpersonen bekommen den Satz zum Beginn nach Ablauf der Widerrufsfrist, auch ohne Daten; Unternehmen nicht', () => {
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
const termine = (d) => d.abschnitte.find((a) => a.id === 'termine')?.text
const mitDaten = termine(daten(privat))
assert.equal(mitDaten, AB_TEXTE.termine({ start: '12.10.2026', fertig: '15.11.2026', privat: true }))
assert.ok(mitDaten.includes(FRUEHBEGINN_SATZ) && mitDaten.includes('12.10.2026') && mitDaten.includes('15.11.2026'))
const ohneDaten = termine(daten({ ...privat, startAm: '', fertigBis: '' }))
assert.equal(ohneDaten, AB_TEXTE.termine({ start: null, fertig: null, privat: true }))
assert.ok(ohneDaten.includes(FRUEHBEGINN_SATZ), 'der Abschnitt „Termine“ steht auch ohne Start- und Fertigdatum')
assert.equal(termine(daten()).includes(FRUEHBEGINN_SATZ), false)
assert.equal(termine(daten({ startAm: '', fertigBis: '' })), undefined, 'Unternehmen ohne Daten: kein Abschnitt')
})
test('Privatperson: Widerrufsbelehrung und Muster-Widerrufsformular gehören dazu, bei Unternehmen nicht', () => {
assert.equal(daten().widerruf, null)
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
const d = daten(privat)
assert.deepEqual(d.widerruf, { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR })
assert.deepEqual(d.empfaenger, ['Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
})
test('Ersetzte AB: Hinweis mit Nummer und Datum nur, wenn ersetzt übergeben wird', () => {
assert.equal(daten().ersetzt, null)
assert.equal(daten({}, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }).ersetzt, AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))
})
test('Fuß: Bankverbindung zeilenweise, Kontakt; leere Angaben lassen ihre Zeilen weg', () => {
const d = daten()
assert.deepEqual(d.fuss.bank, ['Finom Bank', 'IBAN DE12 3456 7890 1234 5678 90', 'BIC FNOMDEB2'])
assert.deepEqual(d.fuss.kontakt, ['WEBklar GbR · vertreten durch Kenso Grimm und Justin Klein', 'Telefon 0176 23726355 · support@webklar.com · webklar.com'])
const leer = auftragPdfDaten(beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, kundennummer: '' } }), { settings: {}, jetzt: JETZT })
assert.deepEqual(leer.fuss.bank, [])
assert.equal(leer.kundennummer, '')
assert.equal(leer.absender.zeilen.at(-1), 'webklar.com', 'ohne Steuernummer entfällt die Zeile')
assert.equal(auftragPdfDaten(beispielAuftrag(), { jetzt: JETZT }).fuss.bank.length, 0, 'ohne settings')
})
test('Wächter: alle Texte, die ins PDF kommen, haben ein Glyph in Figtree (die Ersetzung greift dort nie ein)', () => {
const kunde = beispielAuftrag().kunde
const faelle = [
daten({ kunde: { ...kunde, kundenart: 'privat' } }, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }),
daten({ kunde: { ...kunde, kundenart: 'privat' }, startAm: '', fertigBis: '' }),
daten({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }),
daten({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in drei Raten.' } }),
daten(aufwandAuftrag),
daten({ ...aufwandAuftrag, schaetzungMinuten: null }),
]
for (const d of faelle) assert.deepEqual(bereinigeTexte(d), d)
})
+35
View File
@@ -0,0 +1,35 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { beispielAuftrag, beispielSettings, leseSeiten } from './auftraegePdfHilfe.js'
// Die alte AB liegt im Appwrite-Nachbau (Schema aus L0); erzeugeAuftragPdf liest sie über getAuftrag aus auftragStore.js
const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') } })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { erzeugeAuftragPdf } = await import('../server/services/auftraege/pdf.js')
const { AB_TEXTE } = await import('../server/services/auftraege/texte.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const optionen = { settings: beispielSettings, jetzt: new Date('2026-10-06T10:00:00.000Z') }
const ohneLeerraum = (text) => text.replace(/\s+/g, '')
const text = (pdf) => ohneLeerraum(leseSeiten(pdf).flat().join(''))
const hinweis = ohneLeerraum(AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))
aw.seed('auftraege', { $id: 'alt1', kundenId: 'k1', status: 'versendet', nummer: 'AB-2026-0003', gesendetAm: '2026-10-01T09:00:00.000+00:00' })
test('ersetztId ohne Option: die alte AB wird gelesen, ihre Nummer und ihr Berliner Datum stehen im PDF', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'alt1' }), optionen)
assert.ok(text(pdf).includes(hinweis))
})
test('ersetztId verweist auf eine unbekannte AB: AuftragError 409 statt PDF ohne den Hinweis', async () => {
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'gibtsnicht' }), optionen), (e) => e instanceof AuftragError && e.status === 409)
})
test('ersetzt: null unterdrückt Hinweis und Lesen, auch bei gesetztem ersetztId', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'gibtsnicht' }), { ...optionen, ersetzt: null })
assert.equal(text(pdf).includes(hinweis), false)
})
+102
View File
@@ -0,0 +1,102 @@
import { inflateSync } from 'node:zlib'
/** Beispiel-Auftrag (V3-Form) für die PDF-Tests; `extra` überschreibt einzelne Felder. */
export function beispielAuftrag(extra = {}) {
return {
id: 'ab1', nummer: 'AB-2026-0007', jahr: 2026, laufnummer: 7, status: 'versendet', kundenId: 'k1',
kunde: {
name: 'Anna Müller', firma: 'Müller Bäckerei GmbH', strasse: 'Hauptstraße 12', plz: '67722', ort: 'Winnweiler',
land: 'Deutschland', email: 'anna@example.test', kundenart: 'unternehmen', kundennummer: 'K-1042',
},
projektId: 'p1', titel: 'Neue Website Müller Bäckerei', zeitquelle: ['t1'], art: 'festpreis',
positionen: [
{ beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: 1, einzelpreisCents: 60000 },
{ beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: 2, einzelpreisCents: 10000 },
],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-10-12', fertigBis: '2026-11-15',
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', pdfDatei: '', pdfSha256: '',
gesendetAm: '2026-10-06T08:30:00.000Z', gesendetAn: 'anna@example.test', mailStatus: '', mailFehler: '',
tokenHash: '', tokenGueltigBis: null, bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '',
fruehbeginn: false, fruehbeginnFassung: '', storniertAm: null, widerrufenAm: null,
erstelltVon: 'kenso', erstelltAm: '2026-10-06T08:00:00.000Z', geaendertAm: '2026-10-06T08:00:00.000Z',
...extra,
}
}
export const beispielSettings = { steuernummer: '12/345/67890', bankDetailsText: 'Finom Bank\nIBAN DE12 3456 7890 1234 5678 90\nBIC FNOMDEB2' }
/** Alle Zeichenketten in verschachtelten Objekten und Listen (flach gesammelt); Werte unter den Schlüsseln id und art sind keine Texte. */
export function alleTexte(wert, aus = []) {
if (typeof wert === 'string') aus.push(wert)
else if (Array.isArray(wert)) wert.forEach((e) => alleTexte(e, aus))
else if (wert && typeof wert === 'object') {
for (const [schluessel, e] of Object.entries(wert)) if (schluessel !== 'id' && schluessel !== 'art') alleTexte(e, aus)
}
return aus
}
/** Anzahl Seiten eines PDFs (Seitenobjekte, nicht der Seitenbaum). */
export const seitenZahl = (pdf) => (pdf.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length
/**
* Text aus einem mit pdfkit erzeugten PDF (eingebettete Schriften, Identity-H, ToUnicode), je Seite eine Liste
* von Textzeilen in Zeichenreihenfolge. Nur für Tests: kennt genau das, was pdfkit schreibt.
*/
export function leseSeiten(pdf) {
const s = pdf.toString('latin1')
const objekte = new Map()
const re = /(\d+) 0 obj\n/g
let m
while ((m = re.exec(s))) {
const start = m.index + m[0].length
const ende = s.indexOf('\nendobj', start)
const koerper = s.slice(start, ende)
const strom = koerper.indexOf('\nstream\n')
let dict = koerper
let daten = null
if (strom >= 0) {
dict = koerper.slice(0, strom)
const laenge = Number(/\/Length (\d+)/.exec(dict)[1])
daten = pdf.subarray(start + strom + 8, start + strom + 8 + laenge)
if (/\/Filter \/FlateDecode/.test(dict)) daten = inflateSync(daten)
}
objekte.set(Number(m[1]), { dict, daten })
re.lastIndex = ende
}
const tabellen = new Map()
for (const [id, o] of objekte) {
const tu = /\/ToUnicode (\d+) 0 R/.exec(o.dict)
if (!/\/Subtype \/Type0/.test(o.dict) || !tu) continue
const tabelle = []
for (const r of objekte.get(Number(tu[1])).daten.toString('latin1').matchAll(/<([0-9a-f]{4})> <([0-9a-f]{4})> \[([^\]]*)\]/g)) {
let i = parseInt(r[1], 16)
for (const e of r[3].matchAll(/<([0-9a-f ]+)>/g)) tabelle[i++] = e[1].split(' ').map((h) => String.fromCharCode(parseInt(h, 16))).join('')
}
tabellen.set(id, tabelle)
}
const schriftNachName = new Map()
for (const o of objekte.values()) {
if (!/\/Font <</.test(o.dict)) continue
for (const f of o.dict.matchAll(/\/(F\d+) (\d+) 0 R/g)) schriftNachName.set(f[1], tabellen.get(Number(f[2])))
}
const seiten = []
for (const o of objekte.values()) {
if (!/\/Type \/Page(?![s\w])/.test(o.dict)) continue
const inhalt = objekte.get(Number(/\/Contents (\d+) 0 R/.exec(o.dict)[1])).daten.toString('latin1')
const zeilen = []
for (const bt of inhalt.matchAll(/BT\n([\s\S]*?)\nET/g)) {
const tabelle = schriftNachName.get(/\/(F\d+) [\d.]+ Tf/.exec(bt[1])?.[1])
let text = ''
for (const tj of bt[1].matchAll(/\[([^\]]*)\] TJ/g)) {
for (const h of tj[1].matchAll(/<([0-9a-f]+)>/g)) {
for (let i = 0; i < h[1].length; i += 4) text += tabelle[parseInt(h[1].slice(i, i + 4), 16)] ?? '\u{FFFD}'
}
}
zeilen.push(text)
}
seiten.push(zeilen)
}
return seiten
}
+144
View File
@@ -0,0 +1,144 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { auftragPdfDaten, paginiere, baueAtome, BEREICHE, SEITE, SPALTEN, INHALT_BREITE } = await import('../server/services/auftraege/pdf.js')
// Kleine Seiten: erste Seite 0–200 pt, Folgeseiten 20–200 pt
const SEITEN = { erste: { oben: 0, unten: 200 }, folge: { oben: 20, unten: 200 } }
const baustein = (id, hoehe, extra = {}) => ({ id, typ: 'absatz', hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra })
const lage = (seiten) => seiten.map((s) => s.posten.map((p) => `${p.atom.id}${p.wiederholt ? '*' : ''}@${p.y}`))
test('Maße: A4, Inhaltsbreite und Spaltenbreiten passen zusammen', () => {
assert.equal(SEITE.breite, 595.28)
assert.equal(SEITE.hoehe, 841.89)
assert.ok(Math.abs(INHALT_BREITE - 481.88) < 1e-9)
assert.ok(Math.abs(SPALTEN.reduce((a, b) => a + b, 0) - INHALT_BREITE) < 1e-9)
assert.ok(BEREICHE.erste.oben < BEREICHE.folge.oben && BEREICHE.erste.unten === BEREICHE.folge.unten)
assert.ok(BEREICHE.erste.unten < SEITE.hoehe - 80, 'Platz für den Fuß bleibt frei')
})
test('paginiere: alles passt auf eine Seite, Abstände nur zwischen Bausteinen', () => {
const seiten = paginiere([baustein('a', 30, { abstandDavor: 9 }), baustein('b', 20, { abstandDavor: 8 }), baustein('c', 10, { abstandDavor: 8 })], SEITEN)
assert.deepEqual(lage(seiten), [['a@0', 'b@38', 'c@66']])
assert.deepEqual(paginiere([], SEITEN).map((s) => s.posten), [[]], 'auch ohne Inhalt gibt es eine Seite')
})
test('paginiere: was nicht mehr passt, beginnt die nächste Seite ohne Abstand davor', () => {
const seiten = paginiere([baustein('a', 170), baustein('b', 40, { abstandDavor: 5 }), baustein('c', 40, { abstandDavor: 5 })], SEITEN)
assert.deepEqual(lage(seiten), [['a@0'], ['b@20', 'c@65']])
assert.deepEqual(seiten.map((s) => s.nummer), [1, 2])
})
test('paginiere: Überschrift (haengtAnNaechstem) bleibt bei ihrem Absatz', () => {
const seiten = paginiere([baustein('a', 150), baustein('kopf', 15, { abstandDavor: 5, haengtAnNaechstem: true }), baustein('text', 40)], SEITEN)
assert.deepEqual(lage(seiten), [['a@0'], ['kopf@20', 'text@35']])
})
test('paginiere: umbruchDavor beginnt eine neue Seite, aber nie eine leere', () => {
assert.deepEqual(lage(paginiere([baustein('a', 30), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']])
assert.deepEqual(lage(paginiere([baustein('a', 30, { umbruchDavor: true })], SEITEN)), [['a@0']])
assert.deepEqual(lage(paginiere([baustein('a', 200), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']])
})
const tabelle = (zeilen, extra = {}) => {
const kopf = baustein('kopf', 20, { typ: 'tabellenkopf', haengtAnNaechstem: true })
const rows = Array.from({ length: zeilen }, (_, i) => baustein(`z${i + 1}`, 30, { typ: 'zeile', kopf, haengtAnNaechstem: i === zeilen - 1 && extra.mitSumme }))
return [kopf, ...rows, ...(extra.mitSumme ? [baustein('summe', 30, { haengtAnNaechstem: true }), baustein('hinweis', 10)] : [])]
}
test('paginiere: lange Tabelle läuft über mehrere Seiten, der Kopf wiederholt sich, keine Zeile wird geteilt', () => {
const seiten = paginiere(tabelle(20), SEITEN)
assert.deepEqual(lage(seiten).map((s) => s.length), [7, 6, 6, 5], 'Seite 1: Kopf + 6 Zeilen; Folgeseiten: wiederholter Kopf + je 5 Zeilen, Rest auf der letzten')
assert.equal(seiten.length, 4)
for (const seite of seiten.slice(1)) {
assert.equal(seite.posten[0].atom.id, 'kopf')
assert.equal(seite.posten[0].wiederholt, true)
assert.equal(seite.posten[0].y, 20)
}
const zeilen = seiten.flatMap((s) => s.posten.filter((p) => !p.wiederholt).map((p) => p.atom.id))
assert.deepEqual(zeilen, ['kopf', ...Array.from({ length: 20 }, (_, i) => `z${i + 1}`)])
for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten, `${p.atom.id} ragt über die Seite`)
})
test('paginiere: letzte Zeile, Summe und Hinweis bleiben zusammen, auch wenn die Zeile allein noch gepasst hätte', () => {
// Seite 1: Kopf (20) + 6 Zeilen (180) = 200 – Zeile 6 ist die letzte und hängt an Summe und Hinweis
const seiten = paginiere(tabelle(6, { mitSumme: true }), SEITEN)
assert.deepEqual(lage(seiten), [['kopf@0', 'z1@20', 'z2@50', 'z3@80', 'z4@110', 'z5@140'], ['kopf*@20', 'z6@40', 'summe@70', 'hinweis@100']])
})
test('paginiere: Kette größer als eine Seite wird zwischen Bausteinen getrennt, zu hoher Baustein steht allein', () => {
const kette = [baustein('a', 120, { haengtAnNaechstem: true }), baustein('b', 120, { haengtAnNaechstem: true }), baustein('c', 120)]
assert.deepEqual(lage(paginiere(kette, SEITEN)), [['a@0'], ['b@20'], ['c@20']])
assert.deepEqual(lage(paginiere([baustein('a', 50), baustein('riesig', 500), baustein('b', 50)], SEITEN)), [['a@0'], ['riesig@20'], ['b@20']])
})
// Messfunktion ohne pdfkit: 10 pt je angefangene 60 Zeichen
const messe = (text) => 10 * Math.max(1, Math.ceil(String(text).length / 60))
const bausteine = (extra = {}, optionen = {}) => baueAtome(auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, ...optionen }), messe)
const ids = (atome) => atome.map((a) => a.id)
test('baueAtome Festpreis: Reihenfolge, Zeilenhöhen und Zusammenhalt der Tabelle', () => {
const atome = bausteine()
assert.deepEqual(ids(atome), [
'briefkopf', 'einleitung', 'leistung', 'tabellenkopf', 'zeile-1', 'zeile-2', 'summe', 'kleinunternehmer',
'zahlung-kopf', 'zahlung', 'termine-kopf', 'termine', 'mitwirkung-kopf', 'mitwirkung', 'agb', 'gruss',
])
const nach = (id) => atome.find((a) => a.id === id)
assert.equal(nach('briefkopf').typ, 'briefkopf')
assert.ok(nach('briefkopf').hoehe > 150)
assert.equal(nach('briefkopf').masse.infoZeilen.length, 3)
assert.equal(nach('zeile-1').hoehe, 10 + 12, 'eine Zeile Text plus 2 × 6 pt Rand')
assert.deepEqual([nach('leistung'), nach('tabellenkopf'), nach('zeile-1'), nach('zeile-2'), nach('summe'), nach('kleinunternehmer')].map((a) => a.haengtAnNaechstem), [true, true, false, true, true, false])
assert.equal(nach('zeile-1').kopf, nach('tabellenkopf'))
assert.equal(nach('zeile-2').kopf, nach('tabellenkopf'))
assert.equal(nach('summe').label, 'Gesamtpreis')
assert.ok(atome.every((a) => a.hoehe > 0 && Number.isFinite(a.hoehe)))
})
test('baueAtome Aufwand, ersetzt und Privatperson: nur die passenden Bausteine', () => {
const aufwand = bausteine({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } })
assert.ok(ids(aufwand).includes('aufwand') && !ids(aufwand).includes('tabellenkopf') && !ids(aufwand).includes('summe'))
assert.equal(aufwand.find((a) => a.id === 'aufwand').kasten, true)
const mitErsetzt = bausteine({}, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } })
assert.ok(ids(mitErsetzt).indexOf('ersetzt') > ids(mitErsetzt).indexOf('agb') && ids(mitErsetzt).indexOf('ersetzt') < ids(mitErsetzt).indexOf('gruss'))
assert.equal(ids(bausteine()).some((id) => /^(widerruf|formular)/.test(id)), false)
const privat = bausteine({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } })
const titel = privat.find((a) => a.id === 'widerruf-titel')
assert.equal(titel.umbruchDavor, true)
assert.ok(ids(privat).indexOf('formular-titel') > ids(privat).indexOf('widerruf-titel'))
assert.equal(privat.find((a) => a.id === 'formular-titel').haengtAnNaechstem, true)
assert.equal(privat.at(-1).haengtAnNaechstem, false)
})
test('20 Positionen mit je 200 Zeichen ohne Leerzeichen: alle Zeilen da, mehrere Seiten, Tabellenkopf auf jeder Folgeseite', () => {
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: 'X'.repeat(200), menge: i + 1, einzelpreisCents: 1000 }))
const summe = positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
const seiten = paginiere(bausteine({ positionen, summeCents: summe }), BEREICHE)
assert.ok(seiten.length >= 2)
const zeilen = seiten.flatMap((s) => s.posten.filter((p) => p.atom.typ === 'zeile').map((p) => p.atom.id))
assert.deepEqual(zeilen, Array.from({ length: 20 }, (_, i) => `zeile-${i + 1}`))
for (const seite of seiten.slice(1)) {
const ersteZeile = seite.posten.findIndex((p) => p.atom.typ === 'zeile')
if (ersteZeile >= 0) assert.equal(seite.posten[0].atom.typ, 'tabellenkopf', `Seite ${seite.nummer} beginnt mit dem Tabellenkopf`)
}
const letzte = seiten.at(-1).posten.map((p) => p.atom.id)
assert.ok(letzte.indexOf('summe') > letzte.indexOf('zeile-20'), 'Summe steht bei der letzten Zeile')
for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9)
})
test('lange Widerrufsbelehrung: beginnt auf einer neuen Seite, das Formular bleibt zusammen', () => {
const absatz = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(12)
const d = auftragPdfDaten(beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } }), { settings: beispielSettings })
d.widerruf = { ...d.widerruf, belehrung: { titel: 'Widerrufsbelehrung', abschnitte: Array.from({ length: 6 }, (_, i) => ({ ueberschrift: `Abschnitt ${i + 1}`, absaetze: [absatz, absatz] })) } }
const seiten = paginiere(baueAtome(d, messe), BEREICHE)
const seiteVon = (id) => seiten.find((s) => s.posten.some((p) => p.atom.id === id)).nummer
assert.ok(seiten.length >= 4)
assert.ok(seiteVon('widerruf-titel') >= 2)
assert.equal(seiten[seiteVon('widerruf-titel') - 1].posten.find((p) => !p.wiederholt).atom.id, 'widerruf-titel', 'Belehrung beginnt oben auf einer neuen Seite')
const formular = seiten.flatMap((s) => s.posten.filter((p) => p.atom.id.startsWith('formular')).map(() => s.nummer))
assert.ok(formular.length >= 2 && new Set(formular).size === 1, 'alle Zeilen des Formulars stehen auf derselben Seite')
for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9)
})
+32
View File
@@ -0,0 +1,32 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import PDFDocument from 'pdfkit'
import { useTestEnv } from './support/testEnv.js'
import { leseSeiten, seitenZahl } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { ladeSchriften } = await import('../server/services/auftraege/pdf.js')
// Prüft die Testhilfe selbst an einem kleinen, direkt mit pdfkit gebauten PDF (unabhängig vom AB-Code)
function kleinesPdf(komprimieren) {
const { regular, semibold } = ladeSchriften()
const doc = new PDFDocument({ size: 'A4', compress: komprimieren, info: { CreationDate: new Date('2026-10-06T10:00:00.000Z') } })
doc.registerFont('Figtree', regular)
doc.registerFont('Figtree-SemiBold', semibold)
const teile = []
doc.on('data', (teil) => teile.push(teil))
const fertig = new Promise((resolve) => doc.on('end', () => resolve(Buffer.concat(teile))))
doc.font('Figtree').fontSize(12).text(`Müller & Söhne – ß € ${String.fromCodePoint(0x2212)}5`, 50, 50, { lineBreak: false })
doc.font('Figtree-SemiBold').fontSize(12).text('Zweite Zeile „fett“', 50, 80, { lineBreak: false })
doc.addPage().font('Figtree').fontSize(12).text('Seite zwei', 50, 50, { lineBreak: false })
doc.end()
return fertig
}
test('leseSeiten und seitenZahl: Text je Seite aus einem pdfkit-PDF, komprimiert und unkomprimiert', async () => {
for (const komprimieren of [true, false]) {
const pdf = await kleinesPdf(komprimieren)
assert.equal(seitenZahl(pdf), 2)
assert.deepEqual(leseSeiten(pdf), [[`Müller & Söhne – ß € ${String.fromCodePoint(0x2212)}5`, 'Zweite Zeile „fett“'], ['Seite zwei']])
}
})
+77
View File
@@ -0,0 +1,77 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync } from 'node:fs'
import { useTestEnv } from './support/testEnv.js'
const FONTS = new URL('../server/assets/fonts/', import.meta.url)
const sha256 = (datei) => createHash('sha256').update(readFileSync(new URL(datei, FONTS))).digest('hex')
const cp = (...codes) => String.fromCodePoint(...codes)
const NBSP = cp(0xa0)
test('Schriftdateien: Figtree Regular und SemiBold samt OFL-Lizenz und Quellenangabe liegen unverändert vor', () => {
assert.deepEqual(readdirSync(FONTS).sort(), ['Figtree-OFL.txt', 'Figtree-Regular.ttf', 'Figtree-SemiBold.ttf', 'QUELLE.txt'])
assert.equal(sha256('Figtree-Regular.ttf'), '4e2e9876c4a908c2cbe410ceb648e4724464a84492bd49cb2d6e7b0d3f769a66')
assert.equal(sha256('Figtree-SemiBold.ttf'), '1b76aae525b51758fc0c9c5096f33d90831657d3ad2fe3c34fb7e666d76f8960')
assert.equal(sha256('Figtree-OFL.txt'), '140d37233e7f3ce7313798befa9600893bcceaf41a55fa0fa5ad52f7f657a268')
assert.match(readFileSync(new URL('Figtree-OFL.txt', FONTS), 'utf8'), /^Copyright 2022 The Figtree Project Authors/)
assert.match(readFileSync(new URL('QUELLE.txt', FONTS), 'utf8'), /@expo-google-fonts\/figtree@0\.4\.1/)
})
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { ladeSchriften, hatGlyph, bereinigeText, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
test('ladeSchriften: Dateien werden einmal je Prozess gelesen, jeder Aufruf liefert dasselbe Objekt', () => {
const schriften = ladeSchriften()
assert.equal(ladeSchriften(), schriften)
assert.equal(schriften.regularFont.postscriptName, 'Figtree-Regular')
assert.equal(schriften.semiboldFont.postscriptName, 'Figtree-SemiBold')
assert.equal(schriften.regular.length, 40140)
assert.equal(schriften.logo.subarray(1, 4).toString('latin1'), 'PNG')
})
test('hatGlyph: deutsche Buchstaben, €, Minuszeichen, geschütztes Leerzeichen und typografische Zeichen sind in beiden Schnitten da', () => {
const noetig = [
...'äöüÄÖÜ߀§%/&()@:;,.!?+*#"\'0123456789 ',
NBSP, cp(0x2212), cp(0x2013), cp(0x2014), cp(0x201e), cp(0x201c), cp(0x201d), cp(0x2018), cp(0x2019), cp(0x2026), cp(0x2022), cp(0xb7), cp(0xd7),
'ł', 'ğ', 'Ş', 'ő', 'ř', 'ı',
]
for (const zeichen of noetig) assert.ok(hatGlyph(zeichen.codePointAt(0)), `Glyph fehlt: U+${zeichen.codePointAt(0).toString(16)}`)
for (const code of [0x202f, 0x2009, 0x200b, 0x416, 0x1f600, 0x3042]) assert.equal(hatGlyph(code), false, `U+${code.toString(16)} sollte fehlen`)
})
test('bereinigeText: vorhandene Zeichen bleiben unverändert, getrennte Akzente werden zusammengesetzt', () => {
const text = `Müller & Söhne – „Größe“ 1.234,56${NBSP}€ ${cp(0x2212)} 20,00${NBSP}€ ÄÖÜ äöü ß … Łukasz Yıldız Çelik`
assert.equal(bereinigeText(text), text)
assert.equal(bereinigeText('a' + cp(0x301)), 'á'.normalize('NFC'))
assert.equal(bereinigeText(null), '')
assert.equal(bereinigeText(undefined), '')
assert.equal(bereinigeText(42), '42')
})
test('bereinigeText: Steuerzeichen, Zero-Width, weiche Trennung und BOM entfallen; Zeilenenden werden Zeilenumbrüche, Tab wird Leerzeichen', () => {
const roh = `a${cp(0x200b)}b${cp(0xad)}c${cp(0xfeff)}d${cp(0)}e\tf\r\ng\rh${cp(0x2028)}i${cp(0x200d, 0xfe0f)}j`
assert.equal(bereinigeText(roh), 'abcde f\ng\nh\nij')
})
test('bereinigeText mit Figtree: schmale Leerzeichen werden Leerzeichen, kyrillische und japanische Zeichen sowie Emoji je ein Fragezeichen', () => {
assert.equal(bereinigeText(`a${cp(0x202f)}b${cp(0x2009)}c`), 'a b c')
assert.equal(bereinigeText(`${cp(0x416)}${cp(0x3042)}${cp(0x1f600)}`), '???')
})
test('bereinigeText: Ersatzregeln bei fehlendem Glyph (Schrift ohne Zeichen über ASCII)', () => {
const nurAscii = (code) => code < 0x80
const fall = (eingabe) => bereinigeText(eingabe, nurAscii)
assert.equal(fall('9,00' + NBSP + '€'), '9,00 EUR')
assert.equal(fall(`${cp(0x201e)}x${cp(0x201c)} ${cp(0x201a)}y${cp(0x2019)}`), `"x" 'y'`)
assert.equal(fall(`1 ${cp(0x2013)} 2 ${cp(0x2212)} 3 ${cp(0x2014)} 4`), '1 - 2 - 3 - 4')
assert.equal(fall(`wait${cp(0x2026)} 3${cp(0xd7)}4 a${cp(0x2192)}b`), 'wait... 3x4 a->b')
assert.equal(fall('Müller Çelik Ångström'), 'Muller Celik Angstrom')
assert.equal(fall('Łukasz Ğ'), '?ukasz G')
assert.equal(fall('e' + cp(0x301) + cp(0x301)), 'e')
assert.equal(fall(cp(0x301)), '')
})
test('bereinigeTexte: bearbeitet jede Zeichenkette in verschachtelten Objekten und Listen, andere Werte bleiben', () => {
const roh = { a: ['x' + cp(0x200b) + 'y', { b: 'ż' + cp(0x1f600) }], n: 5, f: null, t: true }
assert.deepEqual(bereinigeTexte(roh), { a: ['xy', { b: 'ż?' }], n: 5, f: null, t: true })
})