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