lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export fusszeilenbreite.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 type { Project } from '@/domain/model/project'; |
| 9 | |
| 10 | /* |
| 11 | * BEFUND 5 (Fassung 5.11.0): Die linke Fusszeile ging ohne Breitenbegrenzung an |
| 12 | * doc.text. |
| 13 | * |
| 14 | * Was der Anwender sonst erlebt: Er traegt in der Ansicht "Projekt" unter |
| 15 | * "Bearbeiter" die uebliche Buerobezeichnung statt eines Personennamens ein - |
| 16 | * das Feld hat keine Laengengrenze. Auf JEDEM Blatt der Unterlage schiebt sich |
| 17 | * die Zeile "Ausgedruckt am ... Bearbeiter: ..." dann unter die rechtsbuendige |
| 18 | * Angabe "Seite x von y" und laeuft schliesslich ueber die Blattkante hinaus. |
| 19 | * Unlesbar wird dabei ausgerechnet die Angabe, die die Vollstaendigkeit des |
| 20 | * Schriftstuecks belegt. |
| 21 | * |
| 22 | * Beide KOPFzeilen sind seit Fassung 5.9.0 (Befund 43) auf `gekuerztAufBreite` |
| 23 | * umgestellt; die Fusszeile derselben Schleife ging leer aus. |
| 24 | */ |
| 25 | |
| 26 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 27 | |
| 28 | const PAGE_MARGIN = 36; |
| 29 | const BLATTBREITE = 841.89; |
| 30 | /** Rechter Rand des Satzspiegels. */ |
| 31 | const SATZSPIEGEL_RECHTS = BLATTBREITE - PAGE_MARGIN; |
| 32 | /** Grundlinie der Fusszeile, von der Blattunterkante aus gemessen. */ |
| 33 | const FUSSZEILE_Y = PAGE_MARGIN + 6; |
| 34 | |
| 35 | /** |
| 36 | * Eine Buerobezeichnung, wie sie in einer Anordnungsunterlage vorkommt - |
| 37 | * 192 Zeichen. Ab 146 Zeichen ueberdeckt sie die Seitenangabe, ab 171 laeuft |
| 38 | * sie ueber die Blattkante. |
| 39 | */ |
| 40 | const BUERO = |
| 41 | 'Dipl.-Ing. Maximilian von Wertheim-Schoenbrunn, Ingenieurbuero fuer ' + |
| 42 | 'Verkehrsplanung und Verkehrstechnik GmbH & Co. KG, Musterstadt am Berge, ' + |
| 43 | 'Abteilung Lichtsignalanlagen und Verkehrsmanagement'; |
| 44 | |
| 45 | interface Stueck { |
| 46 | readonly blatt: number; |
| 47 | readonly x: number; |
| 48 | readonly y: number; |
| 49 | readonly text: string; |
| 50 | } |
| 51 | |
| 52 | function entpacke(roh: string, von: number): string | null { |
| 53 | const anfang = roh.indexOf('stream', von); |
| 54 | if (anfang < 0) return null; |
| 55 | const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7); |
| 56 | const ende = roh.indexOf('endstream', start); |
| 57 | if (ende < 0) return null; |
| 58 | try { |
| 59 | return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 60 | } catch { |
| 61 | return null; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | function textstuecke(bytes: Uint8Array): readonly Stueck[] { |
| 66 | const roh = new TextDecoder('latin1').decode(bytes); |
| 67 | const raus: Stueck[] = []; |
| 68 | let blatt = 0; |
| 69 | |
| 70 | for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) { |
| 71 | const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`); |
| 72 | const inhalt = objekt < 0 ? null : entpacke(roh, objekt); |
| 73 | if (inhalt === null) continue; |
| 74 | blatt += 1; |
| 75 | let x = 0; |
| 76 | let y = 0; |
| 77 | let abstand = 0; |
| 78 | for (const roheZeile of inhalt.split('\n')) { |
| 79 | const zeile = roheZeile.trim(); |
| 80 | const tl = /^(-?[\d.]+) TL$/.exec(zeile); |
| 81 | if (tl) { |
| 82 | abstand = Number(tl[1]); |
| 83 | continue; |
| 84 | } |
| 85 | const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); |
| 86 | if (td) { |
| 87 | x = Number(td[1]); |
| 88 | y = Number(td[2]); |
| 89 | continue; |
| 90 | } |
| 91 | let rest = zeile; |
| 92 | while (rest.startsWith('T*')) { |
| 93 | y -= abstand; |
| 94 | rest = rest.slice(2).trim(); |
| 95 | } |
| 96 | const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest); |
| 97 | if (tj) raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); |
| 98 | } |
| 99 | } |
| 100 | return raus; |
| 101 | } |
| 102 | |
| 103 | /** Gemessene Breite - unabhaengig vom Pruefling, mit derselben Schrift. */ |
| 104 | function breite(text: string): number { |
| 105 | const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' }); |
| 106 | doc.setFont('helvetica', 'normal'); |
| 107 | doc.setFontSize(8); |
| 108 | return doc.getTextWidth(text); |
| 109 | } |
| 110 | |
| 111 | function unterlage(planner: string): Uint8Array { |
| 112 | const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); |
| 113 | const projekt: Project = { ...basis, meta: { ...basis.meta, planner } }; |
| 114 | const plan = buildSignalPlan(projekt); |
| 115 | return buildProjectPdf( |
| 116 | projekt, |
| 117 | plan, |
| 118 | validateProject(projekt, plan, STICHTAG), |
| 119 | DEFAULT_PDF_OPTIONS, |
| 120 | STICHTAG, |
| 121 | ); |
| 122 | } |
| 123 | |
| 124 | /** Die beiden Fusszeilentexte je Blatt, von links nach rechts. */ |
| 125 | function fusszeilen(bytes: Uint8Array): Map<number, readonly Stueck[]> { |
| 126 | const je = new Map<number, Stueck[]>(); |
| 127 | for (const s of textstuecke(bytes)) { |
| 128 | if (Math.abs(s.y - FUSSZEILE_Y) > 0.5) continue; |
| 129 | const bisher = je.get(s.blatt) ?? []; |
| 130 | bisher.push(s); |
| 131 | je.set(s.blatt, bisher); |
| 132 | } |
| 133 | for (const [blatt, stuecke] of je) |
| 134 | je.set( |
| 135 | blatt, |
| 136 | [...stuecke].sort((a, b) => a.x - b.x), |
| 137 | ); |
| 138 | return je; |
| 139 | } |
| 140 | |
| 141 | describe('Befund 5 - Bearbeiterangabe in der Fusszeile', () => { |
| 142 | it('haelt die Bearbeiterangabe von der Seitenangabe und vom Satzspiegelrand frei', () => { |
| 143 | const je = fusszeilen(unterlage(BUERO)); |
| 144 | |
| 145 | expect(je.size, 'Vorbedingung: mehrere Blaetter mit Fusszeile').toBeGreaterThan(3); |
| 146 | |
| 147 | for (const [blatt, stuecke] of je) { |
| 148 | expect(stuecke.length, `Blatt ${blatt}: Bearbeiterangabe und Seitenangabe`).toBe(2); |
| 149 | const links = stuecke[0]!; |
| 150 | const seite = stuecke[1]!; |
| 151 | expect(seite.text, `Blatt ${blatt}: rechts steht die Seitenangabe`).toMatch(/^Seite \d+ von/); |
| 152 | |
| 153 | const ende = links.x + breite(links.text); |
| 154 | expect( |
| 155 | ende, |
| 156 | `Blatt ${blatt}: "${links.text}" endet bei ${ende.toFixed(2)}, ` + |
| 157 | `"${seite.text}" beginnt bei ${seite.x.toFixed(2)}`, |
| 158 | ).toBeLessThanOrEqual(seite.x); |
| 159 | expect(ende, `Blatt ${blatt}: endet bei ${ende.toFixed(2)}`).toBeLessThanOrEqual( |
| 160 | SATZSPIEGEL_RECHTS, |
| 161 | ); |
| 162 | } |
| 163 | }); |
| 164 | |
| 165 | it('laesst eine kurze Bearbeiterangabe unveraendert stehen', () => { |
| 166 | const je = fusszeilen(unterlage('O. Willerding')); |
| 167 | const erstes = je.get(1) ?? []; |
| 168 | |
| 169 | expect(erstes.length).toBe(2); |
| 170 | expect(erstes[0]!.text).toContain('Bearbeiter: O. Willerding'); |
| 171 | expect(erstes[0]!.text, 'kurze Angaben werden nicht gekuerzt').not.toContain('...'); |
| 172 | }); |
| 173 | }); |