From 4706f232b561dbdc454e592b98f6dca9193c79da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:48 +0000 Subject: [PATCH] =?UTF-8?q?AB-PDF:=20Ma=C3=9Fe=20und=20Seitenaufteilung=20?= =?UTF-8?q?mit=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']]) +})