AB-PDF: Maße und Seitenaufteilung mit wiederholtem Tabellenkopf

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:12:48 +00:00
1 parent 731bc8c05e
commit 4706f232b5
2 files changed
+145

No files matched your search

+69
View File
@@ -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
}
+76
View File
@@ -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']])
})