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 }