import { readFileSync } from 'node:fs'; import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import type { Project } from '@/domain/model/project'; /** * Keine Tabelle laeuft in die Kopf- oder Fusszeile. * * BEFUND (Fassung 5.8.0): Fast jeder `autoTable`-Aufruf uebergab nur * `margin: { left, right }`. Fuer oben und unten setzt jspdf-autotable dann * seinen eigenen Regelwert von 40 pt an * (`parseSpacing(options.margin, 40 / doc.scaleFactor())`, * jspdf.plugin.autotable.js:937). Das Folgeblatt einer mehrseitigen Tabelle * begann damit bei 40 pt - mitten in der Kopfzeile, die bis 82 pt reicht. * * Gemessen an der vollstaendigen Unterlage mit langen Signalgruppennamen und * vollstaendig besetzter Konfliktmatrix, an der Fassung 5.7.0: * * 8 Signalgruppen: 15 von 33 Blaettern mit Tabellentext in der Kopfzeile * 16 Signalgruppen: 43 von 63 Blaettern * 24 Signalgruppen: 90 von 109 Blaettern * * Der Bezugsstand gehoert dazu: An einem Zwischenstand, an dem der Matrixumbau * schon drin war und diese Korrektur noch nicht, lauten dieselben Zahlen * 41/62 und 84/107. Ein solcher Stand ist in der Historie nicht festgehalten - * wer die Zahlen nachfahren will, braucht den Stand der Fassung 5.7.0. * * Der Text stand dabei auf y = 545,98 - der Projektname steht auf y = 547,28. * Er wurde also nicht verschoben, sondern ueberdruckt. * * Unten galt dasselbe in klein: Mit 40 statt 62 pt durfte eine Tabelle 22 pt * in den Fusssteg laufen. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); /* * Die Bezugslinien, in PDF-Koordinaten (von der Blattunterkante gemessen, * A4 quer = 595,28 pt hoch). Sie stehen hier ausgerechnet und nicht als * Verweis auf den Quelltext: Ein Prueffall, der dieselbe Formel benutzt wie * der Pruefling, prueft nichts. */ const KOPF_TRENNLINIE = 529.28; // PAGE_MARGIN + HEADER_HEIGHT - 16, von oben 66 const KOPFZEILEN_Y = [547.28, 533.28]; // Projektname, Untertitel const SATZSPIEGEL_UNTEN = 62; // PAGE_MARGIN + FOOTER_HEIGHT const FUSSZEILEN_Y = [42]; // Erstellungsvermerk und Blattangabe interface Stueck { readonly blatt: number; readonly y: number; readonly text: string; } /** Textstuecke aller Blaetter mit ihrer Hoehe ueber der Blattunterkante. */ function textstuecke(bytes: Uint8Array): Stueck[] { const roh = new TextDecoder('latin1').decode(bytes); const raus: Stueck[] = []; const suche = /stream\r?\n/g; let treffer: RegExpExecArray | null; let blatt = 0; while ((treffer = suche.exec(roh)) !== null) { const start = treffer.index + treffer[0].length; const ende = roh.indexOf('endstream', start); if (ende < 0) continue; let inhalt: string; try { inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { continue; } if (!inhalt.includes('Td') && !inhalt.includes('Tj')) continue; blatt += 1; let y = 0; for (const zeile of inhalt.split('\n')) { const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile.trim()); if (td) { y = Number(td[2]); continue; } const tj = /^\((.*)\)\s*Tj$/.exec(zeile.trim()); if (tj && tj[1]!.trim() !== '') raus.push({ blatt, y, text: tj[1]! }); } } return raus; } function nahe(wert: number, ziele: readonly number[]): boolean { return ziele.some((z) => Math.abs(wert - z) < 0.5); } /** Knotenpunkt mit n Signalgruppen, langen Namen und voll besetzter Konfliktmatrix. */ function pruefprojekt(n: number): Project { const basis = projektAusAngaben({ ...ASSISTENT_VORGABE, form: 'kreuzung', fussgaenger: true, rad: true, linksabbieger: true, }); const muster = basis.conflicts[0]!; const gruppen = []; for (let i = 0; i < n; i += 1) { const vorlage = basis.signalGroups[i % basis.signalGroups.length]!; gruppen.push({ ...vorlage, id: `sg-${i}`, name: `Signalgruppe ${i + 1} Nordwest Geradeaus und Rechtsabbieger`, }); } const konflikte = []; for (let i = 0; i < n; i += 1) { for (let j = i + 1; j < n; j += 1) { konflikte.push({ ...muster, id: `c-${i}-${j}`, fromId: `sg-${i}`, toId: `sg-${j}` }); } } return { ...basis, signalGroups: gruppen, conflicts: konflikte }; } function unterlage(n: number): Uint8Array { const p = pruefprojekt(n); return buildProjectPdf(p, buildSignalPlan(p), validateProject(p), DEFAULT_PDF_OPTIONS, STICHTAG); } describe('Satzspiegel der Tabellen', () => { for (const n of [8, 16]) { it(`laesst bei ${n} Signalgruppen die Kopfzeile frei`, () => { const stuecke = textstuecke(unterlage(n)); expect(stuecke.length, 'Vorbedingung: die Unterlage traegt Text').toBeGreaterThan(100); const fremd = stuecke.filter((s) => s.y > KOPF_TRENNLINIE && !nahe(s.y, KOPFZEILEN_Y)); const blaetter = new Set(fremd.map((s) => s.blatt)); expect( fremd.length, fremd.length ? `auf ${blaetter.size} Blatt, zuerst y=${fremd[0]!.y}: "${fremd[0]!.text.slice(0, 50)}"` : '', ).toBe(0); }); it(`laesst bei ${n} Signalgruppen den Fusssteg frei`, () => { const stuecke = textstuecke(unterlage(n)); const fremd = stuecke.filter( (s) => s.y < SATZSPIEGEL_UNTEN && s.y > 0 && !nahe(s.y, FUSSZEILEN_Y), ); const blaetter = new Set(fremd.map((s) => s.blatt)); expect( fremd.length, fremd.length ? `auf ${blaetter.size} Blatt, zuerst y=${fremd[0]!.y}: "${fremd[0]!.text.slice(0, 50)}"` : '', ).toBe(0); }); } /* * Der eigentliche Waechter. Die beiden Faelle darueber pruefen das Ergebnis * an zwei Groessen; dieser prueft die Ursache. Der Fehler entstand dadurch, * dass jeder Aufruf seinen Rand selbst mitbrachte und dabei zwei Seiten * vergass - eine kuenftige Tabelle koennte denselben Weg gehen, ohne dass * einer der Messfaelle darauf faellt: Sie wird ja erst mehrseitig, wenn * jemand ein grosses Projekt ausdruckt. */ it('setzt an jeder Tabelle denselben Satzspiegel', () => { const quelle = readFileSync('src/services/export/pdf.ts', 'utf8'); const aufrufe = (quelle.match(/autoTable\(doc/g) ?? []).length; const mitKonstante = (quelle.match(/margin: TABELLENRAND,/g) ?? []).length; expect(aufrufe, 'Vorbedingung: die Unterlage setzt Tabellen').toBeGreaterThan(15); expect(mitKonstante, `${aufrufe} Aufrufe, aber nur ${mitKonstante} mit TABELLENRAND`).toBe( aufrufe, ); // Und die Konstante nennt wirklich alle vier Seiten. const block = /const TABELLENRAND = \{([\s\S]*?)\} as const;/.exec(quelle); expect(block, 'TABELLENRAND ist definiert').not.toBeNull(); for (const seite of ['left:', 'right:', 'top:', 'bottom:']) { expect(block![1]!, `TABELLENRAND nennt ${seite}`).toContain(seite); } }); });