import { readFileSync } from 'node:fs' import * as fontkitModul from 'fontkit' import { euroDe } from '../finance/format.js' import { berlinDate, formatDe } from '../finance/dates.js' import { AuftragError } from './fehler.js' import { AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN } from './konstanten.js' import { AB_TEXTE, ABSENDER } from './texte.js' import { aufwandText } 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' 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, } }