lsa-planer

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

/ tests export kopfzeilenbreite.test.ts

9,0 KB Rohdatei
tests/export/kopfzeilenbreite.test.ts — 242 Zeilen
1 import { inflateSync } from 'node:zlib';
2 import { describe, expect, it } from 'vitest';
3 import { jsPDF } from 'jspdf';
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 { createStandardIntersectionProject } from '@/domain/model/factory';
8 import { toWinAnsi } from '@/render/pdfSurface';
9 import type { Project } from '@/domain/model/project';
10
11 /*
12 * Befund 43: Kopfzeile und Deckblatttitel druckten die Projektbezeichnung ohne
13 * jede Breitenbegrenzung.
14 *
15 * Was der Anwender sonst erlebt: Er traegt in der Ansicht "Projekt" eine
16 * uebliche lange Bezeichnung ein ("Erneuerung der Lichtsignalanlage
17 * Bundesstrasse ... Planfall 2 (Variante Nord)", 124 Zeichen; das Feld hat
18 * keine Laengengrenze) und erzeugt die Unterlage. In der Kopfzeile JEDER Seite
19 * laeuft der Name in die rechts stehende Grundlagenangabe hinein - beide sind
20 * an der Ueberlappung unlesbar, gerade bei einstreifiger Verkehrsfuehrung, wo
21 * die Kopfzeile das Verhaeltnis von RSA 21 und RiLSA nennt. Auf dem Deckblatt
22 * laeuft derselbe Name in 15 pt ueber die Blattkante und ist dort
23 * abgeschnitten.
24 */
25
26 const STICHTAG = new Date('2026-01-01T12:00:00Z');
27
28 const PAGE_MARGIN = 36;
29 const FOOTER_HEIGHT = 26;
30 const BLATTBREITE = 841.89;
31 /** Rechter Rand des Satzspiegels. */
32 const SATZSPIEGEL_RECHTS = BLATTBREITE - PAGE_MARGIN;
33 /** Grundlinie der Kopfzeile mit dem Projektnamen, von der Blattunterkante. */
34 const KOPFZEILE_Y = 547.28;
35 /** Untere Kante des Satzspiegels, von der Blattunterkante aus gemessen. */
36 const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT;
37 /** Grundlinie der Fusszeile - der einzige Text, der darunter stehen darf. */
38 const FUSSZEILE_Y = PAGE_MARGIN + 6;
39
40 const LANGER_NAME =
41 'Erneuerung der Lichtsignalanlage Bundesstrasse 51 / Kreisstrasse 14, ' +
42 'Ortsdurchfahrt Musterhausen, Planfall 2 (Variante Nord)';
43
44 /**
45 * Rund 3.000 Zeichen - der Deckblatttitel fuellt damit gut 30 Zeilen, das
46 * Deckblatt traegt aber nur 23. Das Schlusswort steht am Ende, weil die
47 * Kopfzeile den Namen kuerzt und ihn deshalb nie enthaelt.
48 */
49 const SEHR_LANGER_NAME = `${Array.from(
50 { length: 70 },
51 (_, i) => `Bauabschnitt ${i + 1} Ortsdurchfahrt Musterhausen`,
52 ).join(' ')} Schlussmerkmal`;
53
54 interface Stueck {
55 readonly blatt: number;
56 readonly x: number;
57 readonly y: number;
58 readonly text: string;
59 }
60
61 function entpacke(roh: string, von: number): string | null {
62 const anfang = roh.indexOf('stream', von);
63 if (anfang < 0) return null;
64 const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7);
65 const ende = roh.indexOf('endstream', start);
66 if (ende < 0) return null;
67 try {
68 return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
69 } catch {
70 return null;
71 }
72 }
73
74 function textstuecke(bytes: Uint8Array): readonly Stueck[] {
75 const roh = new TextDecoder('latin1').decode(bytes);
76 const raus: Stueck[] = [];
77 let blatt = 0;
78
79 for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) {
80 const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`);
81 const inhalt = objekt < 0 ? null : entpacke(roh, objekt);
82 if (inhalt === null) continue;
83 blatt += 1;
84 let x = 0;
85 let y = 0;
86 let abstand = 0;
87 for (const roheZeile of inhalt.split('\n')) {
88 const zeile = roheZeile.trim();
89 const tl = /^(-?[\d.]+) TL$/.exec(zeile);
90 if (tl) {
91 abstand = Number(tl[1]);
92 continue;
93 }
94 const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile);
95 if (td) {
96 x = Number(td[1]);
97 y = Number(td[2]);
98 continue;
99 }
100 let rest = zeile;
101 while (rest.startsWith('T*')) {
102 y -= abstand;
103 rest = rest.slice(2).trim();
104 }
105 const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest);
106 if (tj) raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') });
107 }
108 }
109 return raus;
110 }
111
112 /** Gemessene Breite - unabhaengig vom Pruefling, mit derselben Schrift. */
113 function breite(text: string, groesse: number, stil: 'normal' | 'bold'): number {
114 const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' });
115 doc.setFont('helvetica', stil);
116 doc.setFontSize(groesse);
117 return doc.getTextWidth(text);
118 }
119
120 function unterlage(anlagenart: Project['anlagenart'], name = LANGER_NAME): Uint8Array {
121 const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG);
122 const projekt: Project = {
123 ...basis,
124 anlagenart,
125 meta: { ...basis.meta, name },
126 };
127 const plan = buildSignalPlan(projekt);
128 return buildProjectPdf(
129 projekt,
130 plan,
131 validateProject(projekt, plan, STICHTAG),
132 DEFAULT_PDF_OPTIONS,
133 STICHTAG,
134 );
135 }
136
137 describe('Lange Projektbezeichnung im Ausdruck', () => {
138 for (const anlagenart of ['knotenpunkt', 'einstreifig'] as const) {
139 it(`haelt bei "${anlagenart}" den Namen der Kopfzeile von der Grundlagenangabe frei`, () => {
140 const stuecke = textstuecke(unterlage(anlagenart));
141 const kopf = stuecke.filter((s) => Math.abs(s.y - KOPFZEILE_Y) < 0.5);
142 const blaetter = new Set(kopf.map((s) => s.blatt));
143
144 expect(blaetter.size, 'Vorbedingung: mehrere Blaetter mit Kopfzeile').toBeGreaterThan(3);
145
146 for (const blatt of blaetter) {
147 const aufBlatt = [...kopf].filter((s) => s.blatt === blatt).sort((a, b) => a.x - b.x);
148 expect(aufBlatt.length, `Blatt ${blatt}: Name und Grundlagenangabe`).toBe(2);
149 const name = aufBlatt[0]!;
150 const grundlage = aufBlatt[1]!;
151 const ende = name.x + breite(name.text, 9, 'bold');
152 expect(
153 ende,
154 `Blatt ${blatt}: "${name.text}" endet bei ${ende.toFixed(2)}, ` +
155 `"${grundlage.text}" beginnt bei ${grundlage.x.toFixed(2)}`,
156 ).toBeLessThanOrEqual(grundlage.x);
157 }
158 });
159 }
160
161 it('haelt die zweite Kopfzeile innerhalb des Satzspiegels', () => {
162 const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG);
163 const projekt: Project = {
164 ...basis,
165 meta: {
166 ...basis.meta,
167 name: 'Kurz',
168 projectNumber: LANGER_NAME,
169 location: LANGER_NAME,
170 variant: LANGER_NAME,
171 },
172 };
173 const plan = buildSignalPlan(projekt);
174 const stuecke = textstuecke(
175 buildProjectPdf(
176 projekt,
177 plan,
178 validateProject(projekt, plan, STICHTAG),
179 DEFAULT_PDF_OPTIONS,
180 STICHTAG,
181 ),
182 );
183 const zweite = stuecke.filter((s) => Math.abs(s.y - 533.28) < 0.5 && s.x === PAGE_MARGIN);
184 expect(zweite.length, 'Vorbedingung: die zweite Kopfzeile steht im Dokument').toBeGreaterThan(
185 3,
186 );
187
188 for (const s of zweite) {
189 const ende = s.x + breite(s.text, 9, 'normal');
190 expect(ende, `endet bei ${ende.toFixed(2)}: "${s.text}"`).toBeLessThanOrEqual(
191 SATZSPIEGEL_RECHTS,
192 );
193 }
194 });
195
196 it('setzt den Deckblatttitel vollstaendig und innerhalb des Blattes', () => {
197 const stuecke = textstuecke(unterlage('einstreifig')).filter((s) => s.blatt === 1);
198 // Der Titel steht unter der Ueberschrift "Signalzeitenplan" und ueber der
199 // ersten Tabelle - alles oberhalb der Kopfzeile scheidet aus.
200 const titel = stuecke.filter(
201 (s) => s.y < 490 && s.y > 400 && s.x === PAGE_MARGIN && s.text !== 'Signalzeitenplan',
202 );
203
204 expect(titel.length, 'Vorbedingung: der Deckblatttitel steht im Dokument').toBeGreaterThan(0);
205 // Kein Bestandteil laeuft ueber den Satzspiegel hinaus.
206 for (const s of titel) {
207 const ende = s.x + breite(s.text, 15, 'bold');
208 expect(ende, `endet bei ${ende.toFixed(2)}: "${s.text}"`).toBeLessThanOrEqual(
209 SATZSPIEGEL_RECHTS,
210 );
211 }
212 // Und der Name steht vollstaendig da - auf dem Deckblatt wird umbrochen,
213 // nicht gekuerzt.
214 expect(titel.map((s) => s.text).join(' ')).toBe(toWinAnsi(LANGER_NAME));
215 });
216
217 /*
218 * Nachtrag zu Befund 43: Die Breite war gemessen, die Hoehe nicht. Der
219 * Titel wurde Zeile fuer Zeile nach unten gesetzt, ohne Folgeblatt.
220 *
221 * Was der Anwender sonst erlebt: Er traegt - etwa aus einer eingelesenen
222 * Altdatei uebernommen - eine sehr lange Bezeichnung ein. Auf dem Deckblatt
223 * laeuft der Titel dann in den Fusssteg, ueberdruckt die Fusszeile und faellt
224 * ab rund 2.700 Zeichen ganz aus dem Blatt - ohne jeden Hinweis.
225 */
226 it('setzt einen sehr langen Deckblatttitel auf Folgeblaettern fort statt unter den Satzspiegel', () => {
227 const stuecke = textstuecke(unterlage('knotenpunkt', SEHR_LANGER_NAME));
228
229 const imFusssteg = stuecke.filter(
230 (s) => s.y < SATZSPIEGEL_UNTEN && Math.abs(s.y - FUSSZEILE_Y) > 0.5,
231 );
232 expect(
233 imFusssteg.map((s) => `Blatt ${s.blatt}, y ${s.y.toFixed(2)}: "${s.text}"`),
234 'kein Text unterhalb des Satzspiegels ausser der Fusszeile',
235 ).toEqual([]);
236
237 // Gegenprobe: Die letzte Titelzeile steht im Dokument - auf dem Deckblatt
238 // wird umbrochen, nicht gekuerzt.
239 const schluss = stuecke.filter((s) => s.text.includes('Schlussmerkmal'));
240 expect(schluss.length, 'die letzte Titelzeile fehlt im Dokument').toBe(1);
241 });
242 });