lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests export pdfSatzspiegel.test.ts

7,0 KB Rohdatei
tests/export/pdfSatzspiegel.test.ts — 182 Zeilen
1 import { readFileSync } from 'node:fs';
2 import { inflateSync } from 'node:zlib';
3 import { describe, expect, it } from 'vitest';
4 import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf';
5 import { buildSignalPlan } from '@/domain/plan/signalPlan';
6 import { validateProject } from '@/domain/validation';
7 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
8 import type { Project } from '@/domain/model/project';
9
10 /**
11 * Keine Tabelle laeuft in die Kopf- oder Fusszeile.
12 *
13 * BEFUND (Fassung 5.8.0): Fast jeder `autoTable`-Aufruf uebergab nur
14 * `margin: { left, right }`. Fuer oben und unten setzt jspdf-autotable dann
15 * seinen eigenen Regelwert von 40 pt an
16 * (`parseSpacing(options.margin, 40 / doc.scaleFactor())`,
17 * jspdf.plugin.autotable.js:937). Das Folgeblatt einer mehrseitigen Tabelle
18 * begann damit bei 40 pt - mitten in der Kopfzeile, die bis 82 pt reicht.
19 *
20 * Gemessen an der vollstaendigen Unterlage mit langen Signalgruppennamen und
21 * vollstaendig besetzter Konfliktmatrix, an der Fassung 5.7.0:
22 *
23 * 8 Signalgruppen: 15 von 33 Blaettern mit Tabellentext in der Kopfzeile
24 * 16 Signalgruppen: 43 von 63 Blaettern
25 * 24 Signalgruppen: 90 von 109 Blaettern
26 *
27 * Der Bezugsstand gehoert dazu: An einem Zwischenstand, an dem der Matrixumbau
28 * schon drin war und diese Korrektur noch nicht, lauten dieselben Zahlen
29 * 41/62 und 84/107. Ein solcher Stand ist in der Historie nicht festgehalten -
30 * wer die Zahlen nachfahren will, braucht den Stand der Fassung 5.7.0.
31 *
32 * Der Text stand dabei auf y = 545,98 - der Projektname steht auf y = 547,28.
33 * Er wurde also nicht verschoben, sondern ueberdruckt.
34 *
35 * Unten galt dasselbe in klein: Mit 40 statt 62 pt durfte eine Tabelle 22 pt
36 * in den Fusssteg laufen.
37 */
38
39 const STICHTAG = new Date('2026-01-01T00:00:00Z');
40
41 /*
42 * Die Bezugslinien, in PDF-Koordinaten (von der Blattunterkante gemessen,
43 * A4 quer = 595,28 pt hoch). Sie stehen hier ausgerechnet und nicht als
44 * Verweis auf den Quelltext: Ein Prueffall, der dieselbe Formel benutzt wie
45 * der Pruefling, prueft nichts.
46 */
47 const KOPF_TRENNLINIE = 529.28; // PAGE_MARGIN + HEADER_HEIGHT - 16, von oben 66
48 const KOPFZEILEN_Y = [547.28, 533.28]; // Projektname, Untertitel
49 const SATZSPIEGEL_UNTEN = 62; // PAGE_MARGIN + FOOTER_HEIGHT
50 const FUSSZEILEN_Y = [42]; // Erstellungsvermerk und Blattangabe
51
52 interface Stueck {
53 readonly blatt: number;
54 readonly y: number;
55 readonly text: string;
56 }
57
58 /** Textstuecke aller Blaetter mit ihrer Hoehe ueber der Blattunterkante. */
59 function textstuecke(bytes: Uint8Array): Stueck[] {
60 const roh = new TextDecoder('latin1').decode(bytes);
61 const raus: Stueck[] = [];
62 const suche = /stream\r?\n/g;
63 let treffer: RegExpExecArray | null;
64 let blatt = 0;
65 while ((treffer = suche.exec(roh)) !== null) {
66 const start = treffer.index + treffer[0].length;
67 const ende = roh.indexOf('endstream', start);
68 if (ende < 0) continue;
69 let inhalt: string;
70 try {
71 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
72 } catch {
73 continue;
74 }
75 if (!inhalt.includes('Td') && !inhalt.includes('Tj')) continue;
76 blatt += 1;
77 let y = 0;
78 for (const zeile of inhalt.split('\n')) {
79 const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile.trim());
80 if (td) {
81 y = Number(td[2]);
82 continue;
83 }
84 const tj = /^\((.*)\)\s*Tj$/.exec(zeile.trim());
85 if (tj && tj[1]!.trim() !== '') raus.push({ blatt, y, text: tj[1]! });
86 }
87 }
88 return raus;
89 }
90
91 function nahe(wert: number, ziele: readonly number[]): boolean {
92 return ziele.some((z) => Math.abs(wert - z) < 0.5);
93 }
94
95 /** Knotenpunkt mit n Signalgruppen, langen Namen und voll besetzter Konfliktmatrix. */
96 function pruefprojekt(n: number): Project {
97 const basis = projektAusAngaben({
98 ...ASSISTENT_VORGABE,
99 form: 'kreuzung',
100 fussgaenger: true,
101 rad: true,
102 linksabbieger: true,
103 });
104 const muster = basis.conflicts[0]!;
105 const gruppen = [];
106 for (let i = 0; i < n; i += 1) {
107 const vorlage = basis.signalGroups[i % basis.signalGroups.length]!;
108 gruppen.push({
109 ...vorlage,
110 id: `sg-${i}`,
111 name: `Signalgruppe ${i + 1} Nordwest Geradeaus und Rechtsabbieger`,
112 });
113 }
114 const konflikte = [];
115 for (let i = 0; i < n; i += 1) {
116 for (let j = i + 1; j < n; j += 1) {
117 konflikte.push({ ...muster, id: `c-${i}-${j}`, fromId: `sg-${i}`, toId: `sg-${j}` });
118 }
119 }
120 return { ...basis, signalGroups: gruppen, conflicts: konflikte };
121 }
122
123 function unterlage(n: number): Uint8Array {
124 const p = pruefprojekt(n);
125 return buildProjectPdf(p, buildSignalPlan(p), validateProject(p), DEFAULT_PDF_OPTIONS, STICHTAG);
126 }
127
128 describe('Satzspiegel der Tabellen', () => {
129 for (const n of [8, 16]) {
130 it(`laesst bei ${n} Signalgruppen die Kopfzeile frei`, () => {
131 const stuecke = textstuecke(unterlage(n));
132 expect(stuecke.length, 'Vorbedingung: die Unterlage traegt Text').toBeGreaterThan(100);
133
134 const fremd = stuecke.filter((s) => s.y > KOPF_TRENNLINIE && !nahe(s.y, KOPFZEILEN_Y));
135 const blaetter = new Set(fremd.map((s) => s.blatt));
136 expect(
137 fremd.length,
138 fremd.length
139 ? `auf ${blaetter.size} Blatt, zuerst y=${fremd[0]!.y}: "${fremd[0]!.text.slice(0, 50)}"`
140 : '',
141 ).toBe(0);
142 });
143
144 it(`laesst bei ${n} Signalgruppen den Fusssteg frei`, () => {
145 const stuecke = textstuecke(unterlage(n));
146 const fremd = stuecke.filter(
147 (s) => s.y < SATZSPIEGEL_UNTEN && s.y > 0 && !nahe(s.y, FUSSZEILEN_Y),
148 );
149 const blaetter = new Set(fremd.map((s) => s.blatt));
150 expect(
151 fremd.length,
152 fremd.length
153 ? `auf ${blaetter.size} Blatt, zuerst y=${fremd[0]!.y}: "${fremd[0]!.text.slice(0, 50)}"`
154 : '',
155 ).toBe(0);
156 });
157 }
158
159 /*
160 * Der eigentliche Waechter. Die beiden Faelle darueber pruefen das Ergebnis
161 * an zwei Groessen; dieser prueft die Ursache. Der Fehler entstand dadurch,
162 * dass jeder Aufruf seinen Rand selbst mitbrachte und dabei zwei Seiten
163 * vergass - eine kuenftige Tabelle koennte denselben Weg gehen, ohne dass
164 * einer der Messfaelle darauf faellt: Sie wird ja erst mehrseitig, wenn
165 * jemand ein grosses Projekt ausdruckt.
166 */
167 it('setzt an jeder Tabelle denselben Satzspiegel', () => {
168 const quelle = readFileSync('src/services/export/pdf.ts', 'utf8');
169 const aufrufe = (quelle.match(/autoTable\(doc/g) ?? []).length;
170 const mitKonstante = (quelle.match(/margin: TABELLENRAND,/g) ?? []).length;
171 expect(aufrufe, 'Vorbedingung: die Unterlage setzt Tabellen').toBeGreaterThan(15);
172 expect(mitKonstante, `${aufrufe} Aufrufe, aber nur ${mitKonstante} mit TABELLENRAND`).toBe(
173 aufrufe,
174 );
175 // Und die Konstante nennt wirklich alle vier Seiten.
176 const block = /const TABELLENRAND = \{([\s\S]*?)\} as const;/.exec(quelle);
177 expect(block, 'TABELLENRAND ist definiert').not.toBeNull();
178 for (const seite of ['left:', 'right:', 'top:', 'bottom:']) {
179 expect(block![1]!, `TABELLENRAND nennt ${seite}`).toContain(seite);
180 }
181 });
182 });