From ae6d84b2a4693f64b97b09aa636c2e51305b0f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:11:48 +0000 Subject: [PATCH 1/9] =?UTF-8?q?AB-PDF:=20Figtree=20Regular=20und=20SemiBol?= =?UTF-8?q?d=20(OFL)=20mit=20Schriftlader=20und=20Glyphenpr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/assets/fonts/Figtree-OFL.txt | 93 +++++++++++++++++++++++ server/assets/fonts/Figtree-Regular.ttf | Bin 0 -> 40140 bytes server/assets/fonts/Figtree-SemiBold.ttf | Bin 0 -> 40360 bytes server/assets/fonts/QUELLE.txt | 5 ++ server/services/auftraege/pdf.js | 38 +++++++++ test/auftraegePdfSchrift.test.js | 41 ++++++++++ 6 files changed, 177 insertions(+) create mode 100644 server/assets/fonts/Figtree-OFL.txt create mode 100644 server/assets/fonts/Figtree-Regular.ttf create mode 100644 server/assets/fonts/Figtree-SemiBold.ttf create mode 100644 server/assets/fonts/QUELLE.txt create mode 100644 server/services/auftraege/pdf.js create mode 100644 test/auftraegePdfSchrift.test.js 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 0000000000000000000000000000000000000000..4b42cdb30bf8fc0ee298f115f23b02eba07e18c4 GIT binary patch literal 40140 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�znRO6SVN@Y#{E4zCaujrzY3Tm`Vs0D081f8Fp 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

ulFWt}9*Fx^8ma=DNf6 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 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`) +}) From b097fedf5a39455e8526ab1e18d67371c7e7117b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:11:54 +0000 Subject: [PATCH 2/9] =?UTF-8?q?AB-PDF:=20Zeichen=20ohne=20Glyph=20werden?= =?UTF-8?q?=20ersetzt=20statt=20als=20K=C3=A4stchen=20gedruckt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 40 ++++++++++++++++++++++++++++++++ test/auftraegePdfSchrift.test.js | 36 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index d1769c6..0df6385 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -36,3 +36,43 @@ 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 +} + diff --git a/test/auftraegePdfSchrift.test.js b/test/auftraegePdfSchrift.test.js index 92cb589..b2c7201 100644 --- a/test/auftraegePdfSchrift.test.js +++ b/test/auftraegePdfSchrift.test.js @@ -39,3 +39,39 @@ test('hatGlyph: deutsche Buchstaben, €, Minuszeichen, geschütztes Leerzeichen 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 }) +}) From 7ad9c12bca65169ca85f0f3384700e8993f79b7f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:18 +0000 Subject: [PATCH 3/9] =?UTF-8?q?AB-PDF:=20auftragPdfDaten=20mit=20Kopf,=20A?= =?UTF-8?q?nschrift,=20Positionen=20und=20Pr=C3=BCfung=20der=20Pflichtanga?= =?UTF-8?q?ben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 81 ++++++++++++++++++++++++++ test/auftraegePdfDaten.test.js | 99 ++++++++++++++++++++++++++++++++ test/auftraegePdfHilfe.js | 36 ++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 test/auftraegePdfDaten.test.js create mode 100644 test/auftraegePdfHilfe.js diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 0df6385..5d10632 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -1,5 +1,11 @@ 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), @@ -76,3 +82,78 @@ export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) { 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, + } +} + diff --git a/test/auftraegePdfDaten.test.js b/test/auftraegePdfDaten.test.js new file mode 100644 index 0000000..fdc2351 --- /dev/null +++ b/test/auftraegePdfDaten.test.js @@ -0,0 +1,99 @@ +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)) + } +}) diff --git a/test/auftraegePdfHilfe.js b/test/auftraegePdfHilfe.js new file mode 100644 index 0000000..de0dff0 --- /dev/null +++ b/test/auftraegePdfHilfe.js @@ -0,0 +1,36 @@ +/** 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 +} From 731bc8c05e564099093ca8731948b85ef6263132 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:33 +0000 Subject: [PATCH 4/9] =?UTF-8?q?AB-PDF:=20Zahlungsplan,=20Termine,=20AGB-Sa?= =?UTF-8?q?tz,=20Privatperson-Seiten,=20Fu=C3=9F=20und=20Vorschau=20in=20d?= =?UTF-8?q?en=20PDF-Daten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 22 ++++++++-- test/auftraegePdfDaten.test.js | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 5d10632..235b941 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -3,9 +3,9 @@ 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' +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), @@ -127,6 +127,13 @@ export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetz 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, @@ -154,6 +161,15 @@ export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetz } : { 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}`], + }, } } diff --git a/test/auftraegePdfDaten.test.js b/test/auftraegePdfDaten.test.js index fdc2351..d2a616c 100644 --- a/test/auftraegePdfDaten.test.js +++ b/test/auftraegePdfDaten.test.js @@ -97,3 +97,76 @@ test('Fehlende Pflichtangaben und falsche Summe: AuftragError 409 mit Klartext s 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) +}) From 4706f232b561dbdc454e592b98f6dca9193c79da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:48 +0000 Subject: [PATCH 5/9] =?UTF-8?q?AB-PDF:=20Ma=C3=9Fe=20und=20Seitenaufteilun?= =?UTF-8?q?g=20mit=20wiederholtem=20Tabellenkopf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 69 +++++++++++++++++++++++++++++ test/auftraegePdfLayout.test.js | 76 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 test/auftraegePdfLayout.test.js diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 235b941..348f9a0 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -173,3 +173,72 @@ export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetz } } + +// ===== 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 }, +}) + + +/** 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 +} diff --git a/test/auftraegePdfLayout.test.js b/test/auftraegePdfLayout.test.js new file mode 100644 index 0000000..cd9cfee --- /dev/null +++ b/test/auftraegePdfLayout.test.js @@ -0,0 +1,76 @@ +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']]) +}) From f71e3e8345203966dbea313e256409911fd6cd77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:02 +0000 Subject: [PATCH 6/9] =?UTF-8?q?AB-PDF:=20Bausteine=20der=20Auftragsbest?= =?UTF-8?q?=C3=A4tigung=20mit=20H=C3=B6hen=20und=20Zusammenhalt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 57 ++++++++++++++++++++++++++ test/auftraegePdfLayout.test.js | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 348f9a0..25c31a5 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -205,6 +205,63 @@ export const BEREICHE = Object.freeze({ }) +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 = [] diff --git a/test/auftraegePdfLayout.test.js b/test/auftraegePdfLayout.test.js index cd9cfee..70c32d9 100644 --- a/test/auftraegePdfLayout.test.js +++ b/test/auftraegePdfLayout.test.js @@ -74,3 +74,71 @@ test('paginiere: Kette größer als eine Seite wird zwischen Bausteinen getrennt 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) +}) From 01406c82e58e27f97d727582b0b7adb6fb07d1ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:14 +0000 Subject: [PATCH 7/9] =?UTF-8?q?AB-PDF:=20Textleser=20f=C3=BCr=20PDF-Tests?= =?UTF-8?q?=20(ToUnicode,=20nur=20Node-Bordmittel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- test/auftraegePdfHilfe.js | 66 ++++++++++++++++++++++++++++++++++ test/auftraegePdfLeser.test.js | 32 +++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 test/auftraegePdfLeser.test.js diff --git a/test/auftraegePdfHilfe.js b/test/auftraegePdfHilfe.js index de0dff0..5bc738b 100644 --- a/test/auftraegePdfHilfe.js +++ b/test/auftraegePdfHilfe.js @@ -1,3 +1,5 @@ +import { inflateSync } from 'node:zlib' + /** Beispiel-Auftrag (V3-Form) für die PDF-Tests; `extra` überschreibt einzelne Felder. */ export function beispielAuftrag(extra = {}) { return { @@ -34,3 +36,67 @@ export function alleTexte(wert, 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/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']]) + } +}) From 6f213dca254a38114224715e372ced51f60b69a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:34 +0000 Subject: [PATCH 8/9] =?UTF-8?q?AB-PDF:=20erzeugeAuftragPdf=20zeichnet=20Br?= =?UTF-8?q?iefkopf,=20Tabelle=20=C3=BCber=20mehrere=20Seiten,=20Belehrung?= =?UTF-8?q?=20und=20Fu=C3=9F=20mit=20pdfkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 151 +++++++++++++++++++++++++++++++ test/auftraegePdf.test.js | 130 ++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 test/auftraegePdf.test.js diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 25c31a5..654bcff 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -1,5 +1,6 @@ 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' @@ -299,3 +300,153 @@ export function paginiere(atome, bereiche = BEREICHE) { } 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 }) +} + +/** 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 (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 ===== + +/** Das AB-PDF (A4) als Buffer. */ +export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date() } = {}) { + const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt })) + 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..54b8a2e --- /dev/null +++ b/test/auftraegePdf.test.js @@ -0,0 +1,130 @@ +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) +}) From 3464808a40ebc437a7a1f3a6679cd6f651b546a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:51 +0000 Subject: [PATCH 9/9] =?UTF-8?q?AB-PDF:=20Entwurfs-Kennzeichnung=20f=C3=BCr?= =?UTF-8?q?=20die=20Vorschau=20und=20Hinweis=20auf=20die=20ersetzte=20AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 27 ++++++++++++++++++++++-- test/auftraegePdf.test.js | 17 ++++++++++++++++ test/auftraegePdfErsetzt.test.js | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 test/auftraegePdfErsetzt.test.js diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 654bcff..0db6914 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -404,10 +404,25 @@ function zeichneFuss(doc, daten, nummer, gesamt) { 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) @@ -416,9 +431,17 @@ function zeichneAuftrag(doc, daten, seiten, assets) { // ===== 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() } = {}) { - const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt })) +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, diff --git a/test/auftraegePdf.test.js b/test/auftraegePdf.test.js index 54b8a2e..955621e 100644 --- a/test/auftraegePdf.test.js +++ b/test/auftraegePdf.test.js @@ -128,3 +128,20 @@ 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/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) +})