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
}