lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export absatzUmbruch.test.ts
| 1 | import { inflateSync } from 'node:zlib'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf'; |
| 4 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 5 | import { validateProject } from '@/domain/validation'; |
| 6 | import { |
| 7 | createArm, |
| 8 | createEmptyProject, |
| 9 | createPhase, |
| 10 | createSignalGroup, |
| 11 | } from '@/domain/model/factory'; |
| 12 | import type { Project, SignalGroup } from '@/domain/model/project'; |
| 13 | import type { TrafficMode } from '@/domain/rilsa/types'; |
| 14 | |
| 15 | /* |
| 16 | * BEFUNDE 20 UND 51: `writeParagraph` rief `ensureSpace`, |
| 17 | * und `ensureSpace` legt genau EIN Folgeblatt an. Danach setzte |
| 18 | * `doc.text(lines, ...)` alle Zeilen fortlaufend nach unten. |
| 19 | * |
| 20 | * Was der Anwender sonst erlebt: Er fuehrt viele Signalgruppen mit langen |
| 21 | * Bezeichnungen - weder Zahl noch Namenslaenge sind begrenzt, in src/ steht |
| 22 | * kein einziges `maxlength`. Drei Absaetze werden daraus so lang, dass sie |
| 23 | * nicht mehr auf ein Blatt passen: |
| 24 | * |
| 25 | * - der Furtregelabsatz unter der Signalgruppentabelle (Befund 20) - er |
| 26 | * traegt den Rechenweg der Mindestfreigabezeit je Fussgaengergruppe und |
| 27 | * endet mit dem Mittelinselvermerk, also mit der Einschraenkungserklaerung |
| 28 | * zu zweistufigen Furten; |
| 29 | * - der Fahrstreifensatz der Leistungstabelle bei abgewaehlter Tabelle |
| 30 | * "Signalgruppen" (Befund 51) - ohne ihn ist qS nicht prueffaehig; |
| 31 | * - der Vmax-Nachsatz des Anfahransatzes, ebenfalls bei abgewaehlter Tabelle. |
| 32 | * |
| 33 | * Die Zeilen liefen in den Fusssteg, ueberdruckten die Fusszeile und fielen |
| 34 | * schliesslich aus dem Blatt - ohne jeden Hinweis. Fuer Deckblatttitel |
| 35 | * (Befund 43) und Bemerkung (Befund 18) ist genau das mit |
| 36 | * `setzeZeilenMitUmbruch` bereits behoben worden; `writeParagraph` selbst |
| 37 | * nicht. |
| 38 | */ |
| 39 | |
| 40 | const FEST = new Date('2026-01-01T12:00:00Z'); |
| 41 | |
| 42 | const PAGE_MARGIN = 36; |
| 43 | const FOOTER_HEIGHT = 26; |
| 44 | /** Unterkante des Satzspiegels, in PDF-Koordinaten (Ursprung unten). */ |
| 45 | const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT; |
| 46 | /** Grundlinie der Fusszeile - der einzige Text, der darunter stehen darf. */ |
| 47 | const FUSSZEILE_Y = PAGE_MARGIN + 6; |
| 48 | |
| 49 | /** Ein Name der gewuenschten Laenge, wie ihn eine Anordnungsunterlage traegt. */ |
| 50 | function langerName(i: number, laenge: number): string { |
| 51 | let text = |
| 52 | `Fußgängerfurt ${i + 1} über die Hauptstraße nördliche Knotenpunktzufahrt mit Mittelinsel ` + |
| 53 | 'und Blindenzusatz Anforderung über Taster '; |
| 54 | while (text.length < laenge) text += 'Zusatzangabe '; |
| 55 | return text.slice(0, laenge).trim(); |
| 56 | } |
| 57 | |
| 58 | interface Stueck { |
| 59 | readonly blatt: number; |
| 60 | readonly y: number; |
| 61 | readonly text: string; |
| 62 | } |
| 63 | |
| 64 | function entpacke(roh: string, von: number): string | null { |
| 65 | const anfang = roh.indexOf('stream', von); |
| 66 | if (anfang < 0) return null; |
| 67 | const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7); |
| 68 | const ende = roh.indexOf('endstream', start); |
| 69 | if (ende < 0) return null; |
| 70 | try { |
| 71 | return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 72 | } catch { |
| 73 | return null; |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | function textstuecke(bytes: Uint8Array): readonly Stueck[] { |
| 78 | const roh = new TextDecoder('latin1').decode(bytes); |
| 79 | const raus: Stueck[] = []; |
| 80 | let blatt = 0; |
| 81 | |
| 82 | for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) { |
| 83 | const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`); |
| 84 | const inhalt = objekt < 0 ? null : entpacke(roh, objekt); |
| 85 | if (inhalt === null) continue; |
| 86 | blatt += 1; |
| 87 | let y = 0; |
| 88 | let abstand = 0; |
| 89 | for (const roheZeile of inhalt.split('\n')) { |
| 90 | const zeile = roheZeile.trim(); |
| 91 | const tl = /^(-?[\d.]+) TL$/.exec(zeile); |
| 92 | if (tl) { |
| 93 | abstand = Number(tl[1]); |
| 94 | continue; |
| 95 | } |
| 96 | const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); |
| 97 | if (td) { |
| 98 | y = Number(td[2]); |
| 99 | continue; |
| 100 | } |
| 101 | let rest = zeile; |
| 102 | while (rest.startsWith('T*')) { |
| 103 | y -= abstand; |
| 104 | rest = rest.slice(2).trim(); |
| 105 | } |
| 106 | const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest); |
| 107 | if (tj) raus.push({ blatt, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); |
| 108 | } |
| 109 | } |
| 110 | return raus; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Ein Knotenpunkt mit n Signalgruppen einer Verkehrsart und langen Namen, ohne |
| 115 | * Konfliktbeziehungen - dann greift beim Furtregelabsatz der Zweig |
| 116 | * "Furtlaenge unbekannt", und jede Gruppe steuert einen Satz bei. |
| 117 | */ |
| 118 | function projekt(n: number, mode: TrafficMode, laenge: number): Project { |
| 119 | const basis = createEmptyProject('Pruefkreuzung', FEST, 'knotenpunkt'); |
| 120 | const arme = ['Nord', 'Ost', 'Süd', 'West'].map((r) => createArm(r, r, 2, 50)); |
| 121 | |
| 122 | const groups: SignalGroup[] = []; |
| 123 | for (let i = 0; i < n; i += 1) { |
| 124 | groups.push( |
| 125 | createSignalGroup({ |
| 126 | name: langerName(i, laenge), |
| 127 | mode, |
| 128 | armId: arme[i % 4]?.id ?? null, |
| 129 | index: i, |
| 130 | }), |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | const phase = createPhase( |
| 135 | 'Phase 1', |
| 136 | groups.map((g) => g.id), |
| 137 | ); |
| 138 | |
| 139 | return { |
| 140 | ...basis, |
| 141 | intersection: { ...basis.intersection, arms: arme }, |
| 142 | signalGroups: groups, |
| 143 | conflicts: [], |
| 144 | phases: [phase], |
| 145 | program: { ...basis.program, phaseOrder: [phase.id] }, |
| 146 | demands: |
| 147 | mode === 'kfz' |
| 148 | ? groups.map((g, i) => ({ |
| 149 | signalGroupId: g.id, |
| 150 | volume: 300 + ((i * 37) % 400), |
| 151 | heavyVehicleShare: 0.05, |
| 152 | })) |
| 153 | : [], |
| 154 | }; |
| 155 | } |
| 156 | |
| 157 | function unterlage(project: Project, optionen: Partial<PdfOptions> = {}): Uint8Array { |
| 158 | const plan = buildSignalPlan(project); |
| 159 | return buildProjectPdf( |
| 160 | project, |
| 161 | plan, |
| 162 | validateProject(project, plan, FEST), |
| 163 | { ...DEFAULT_PDF_OPTIONS, ...optionen }, |
| 164 | FEST, |
| 165 | ); |
| 166 | } |
| 167 | |
| 168 | /** Jeder Text unterhalb des Satzspiegels - ausser der Fusszeile selbst. */ |
| 169 | function imFusssteg(bytes: Uint8Array): readonly string[] { |
| 170 | return textstuecke(bytes) |
| 171 | .filter((s) => s.y < SATZSPIEGEL_UNTEN && Math.abs(s.y - FUSSZEILE_Y) > 0.5) |
| 172 | .map((s) => `Blatt ${s.blatt}, y ${s.y.toFixed(2)}: "${s.text}"`); |
| 173 | } |
| 174 | |
| 175 | describe('Befunde 20 und 51 - Absaetze ueber mehrere Blaetter', () => { |
| 176 | it('setzt den Furtregelabsatz bei 32 Fussgaengergruppen auf Folgeblaettern fort', () => { |
| 177 | const bytes = unterlage(projekt(32, 'fuss', 200)); |
| 178 | |
| 179 | // Vorbedingung: Der Absatz steht im Dokument und ist wirklich lang. |
| 180 | const zeilen = textstuecke(bytes).filter((s) => s.text.includes('Furtlänge unbekannt')); |
| 181 | expect(zeilen.length, 'Vorbedingung: der Furtregelabsatz steht im Dokument').toBeGreaterThan( |
| 182 | 30, |
| 183 | ); |
| 184 | |
| 185 | expect(imFusssteg(bytes), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile').toEqual( |
| 186 | [], |
| 187 | ); |
| 188 | |
| 189 | // Gegenprobe: Der Mittelinselvermerk steht am Ende des Absatzes und fiel |
| 190 | // deshalb als erstes vom Blatt. |
| 191 | expect( |
| 192 | textstuecke(bytes).filter((s) => s.text.includes('Mittelinsel')).length, |
| 193 | 'der Schluss des Absatzes fehlt im Dokument', |
| 194 | ).toBeGreaterThan(0); |
| 195 | }); |
| 196 | |
| 197 | it('setzt den Fahrstreifensatz bei 40 Kfz-Gruppen ohne Signalgruppentabelle fort', () => { |
| 198 | const bytes = unterlage(projekt(40, 'kfz', 200), { includeSignalGroups: false }); |
| 199 | |
| 200 | const zeilen = textstuecke(bytes).filter((s) => s.text.includes('Fahrstreifen')); |
| 201 | expect(zeilen.length, 'Vorbedingung: der Fahrstreifensatz steht im Dokument').toBeGreaterThan( |
| 202 | 10, |
| 203 | ); |
| 204 | |
| 205 | expect(imFusssteg(bytes), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile').toEqual( |
| 206 | [], |
| 207 | ); |
| 208 | }); |
| 209 | }); |