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:
commit
bd12892ed2
12 files changed
+1282
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user