Merge auftraege/l4-ab-pdf: Auftragsbestätigung PDF (Strang L4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd12892ed2
12 files changed
+1282
No files matched your search
@@ -0,0 +1,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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }))))
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 <</.test(o.dict)) continue
|
||||
for (const f of o.dict.matchAll(/\/(F\d+) (\d+) 0 R/g)) schriftNachName.set(f[1], tabellen.get(Number(f[2])))
|
||||
}
|
||||
const seiten = []
|
||||
for (const o of objekte.values()) {
|
||||
if (!/\/Type \/Page(?![s\w])/.test(o.dict)) continue
|
||||
const inhalt = objekte.get(Number(/\/Contents (\d+) 0 R/.exec(o.dict)[1])).daten.toString('latin1')
|
||||
const zeilen = []
|
||||
for (const bt of inhalt.matchAll(/BT\n([\s\S]*?)\nET/g)) {
|
||||
const tabelle = schriftNachName.get(/\/(F\d+) [\d.]+ Tf/.exec(bt[1])?.[1])
|
||||
let text = ''
|
||||
for (const tj of bt[1].matchAll(/\[([^\]]*)\] TJ/g)) {
|
||||
for (const h of tj[1].matchAll(/<([0-9a-f]+)>/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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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']])
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in new issue
Block a user