diff --git a/server/assets/fonts/Figtree-OFL.txt b/server/assets/fonts/Figtree-OFL.txt new file mode 100644 index 0000000..80b12bb --- /dev/null +++ b/server/assets/fonts/Figtree-OFL.txt @@ -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. diff --git a/server/assets/fonts/Figtree-Regular.ttf b/server/assets/fonts/Figtree-Regular.ttf new file mode 100644 index 0000000..4b42cdb Binary files /dev/null and b/server/assets/fonts/Figtree-Regular.ttf differ diff --git a/server/assets/fonts/Figtree-SemiBold.ttf b/server/assets/fonts/Figtree-SemiBold.ttf new file mode 100644 index 0000000..aa2b78c Binary files /dev/null and b/server/assets/fonts/Figtree-SemiBold.ttf differ diff --git a/server/assets/fonts/QUELLE.txt b/server/assets/fonts/QUELLE.txt new file mode 100644 index 0000000..cdb6809 --- /dev/null +++ b/server/assets/fonts/QUELLE.txt @@ -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 diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js new file mode 100644 index 0000000..0db6914 --- /dev/null +++ b/server/services/auftraege/pdf.js @@ -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 +} diff --git a/test/auftraegePdf.test.js b/test/auftraegePdf.test.js new file mode 100644 index 0000000..955621e --- /dev/null +++ b/test/auftraegePdf.test.js @@ -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' })))) +}) diff --git a/test/auftraegePdfDaten.test.js b/test/auftraegePdfDaten.test.js new file mode 100644 index 0000000..d2a616c --- /dev/null +++ b/test/auftraegePdfDaten.test.js @@ -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) +}) diff --git a/test/auftraegePdfErsetzt.test.js b/test/auftraegePdfErsetzt.test.js new file mode 100644 index 0000000..306e3a1 --- /dev/null +++ b/test/auftraegePdfErsetzt.test.js @@ -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) +}) diff --git a/test/auftraegePdfHilfe.js b/test/auftraegePdfHilfe.js new file mode 100644 index 0000000..5bc738b --- /dev/null +++ b/test/auftraegePdfHilfe.js @@ -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 </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 +} diff --git a/test/auftraegePdfLayout.test.js b/test/auftraegePdfLayout.test.js new file mode 100644 index 0000000..70c32d9 --- /dev/null +++ b/test/auftraegePdfLayout.test.js @@ -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) +}) diff --git a/test/auftraegePdfLeser.test.js b/test/auftraegePdfLeser.test.js new file mode 100644 index 0000000..3cd14f3 --- /dev/null +++ b/test/auftraegePdfLeser.test.js @@ -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']]) + } +}) diff --git a/test/auftraegePdfSchrift.test.js b/test/auftraegePdfSchrift.test.js new file mode 100644 index 0000000..b2c7201 --- /dev/null +++ b/test/auftraegePdfSchrift.test.js @@ -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 }) +})