lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export pdfSatzspiegel.test.ts
| 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 | }); |