lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export luftbildblatt.test.ts
| 1 | import { inflateSync } from 'node:zlib'; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 3 | import { buildProjectPdf, DEFAULT_PDF_OPTIONS, lageplanblattVorlage } from '@/services/export/pdf'; |
| 4 | import { |
| 5 | BLATTBILD_DPI, |
| 6 | BLATTBILD_ZIELBYTES, |
| 7 | blattbildGroesse, |
| 8 | blattbildVorbereiten, |
| 9 | datenBytes, |
| 10 | type Blattbild, |
| 11 | } from '@/services/export/blattbild'; |
| 12 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 13 | import { validateProject } from '@/domain/validation'; |
| 14 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 15 | import { toWinAnsi } from '@/render/pdfSurface'; |
| 16 | import type { Lageplan } from '@/domain/geometrie/lageplan'; |
| 17 | import type { Project } from '@/domain/model/project'; |
| 18 | |
| 19 | /** |
| 20 | * Das zweite Lageplanblatt - derselbe Plan ueber dem Luftbild. |
| 21 | * |
| 22 | * WOZU (aus dem Betrieb, 11.09.2026): Das gedruckte Lageplanblatt zeigte |
| 23 | * Fahrlinien ohne Umgebung und trug keine Angabe zur Verortung. Der Anwender |
| 24 | * fragte, wie man damit ueberhaupt sagen soll, wo die Anlage liegt. Die |
| 25 | * Antwort war: gar nicht - die Herkunftszeile stand im Abschnitt |
| 26 | * "Planungsgrundlage" mehrere Seiten davor. |
| 27 | * |
| 28 | * WARUM DAS BILD NICHT EINFACH MITGEHT: bis zu 24 MiB je Unterlage. Es wird |
| 29 | * auf den gezeigten Ausschnitt zugeschnitten und auf die Aufloesung des |
| 30 | * Blattes verkleinert; danach haengt die Groesse an der Blattgroesse und nicht |
| 31 | * mehr an der Vorlage. |
| 32 | * |
| 33 | * jsdom bringt keine Zeichenflaeche mit. Der Stift schreibt hier mit, was in |
| 34 | * welcher Reihenfolge angeordnet wurde - dasselbe Verfahren wie bei |
| 35 | * `bildGrundfuellung`, und aus demselben Grund: Eine Fuellung NACH dem |
| 36 | * Zeichnen loeschte das Bild. |
| 37 | */ |
| 38 | |
| 39 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 40 | |
| 41 | /** Ein wirkliches, wenn auch winziges JPEG - jsPDF liest die Bilddaten. */ |
| 42 | const ECHTES_JPEG = |
| 43 | 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL' + |
| 44 | 'DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAAB' + |
| 45 | 'AAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=='; |
| 46 | |
| 47 | function pdfText(bytes: Uint8Array): string { |
| 48 | const roh = new TextDecoder('latin1').decode(bytes); |
| 49 | const teile: string[] = []; |
| 50 | const suche = /stream\r?\n/g; |
| 51 | let treffer: RegExpExecArray | null; |
| 52 | while ((treffer = suche.exec(roh)) !== null) { |
| 53 | const start = treffer.index + treffer[0].length; |
| 54 | const ende = roh.indexOf('endstream', start); |
| 55 | if (ende < 0) continue; |
| 56 | let inhalt: string; |
| 57 | try { |
| 58 | inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 59 | } catch { |
| 60 | continue; |
| 61 | } |
| 62 | for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { |
| 63 | teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); |
| 64 | } |
| 65 | } |
| 66 | return teile.join(' ').replace(/\s+/g, ' '); |
| 67 | } |
| 68 | |
| 69 | function lageplan(herkunft: 'gemessen' | 'georeferenziert', mitBild = true): Lageplan { |
| 70 | return { |
| 71 | arbeitsbereiche: [], |
| 72 | signalgeber: [], |
| 73 | bild: mitBild |
| 74 | ? { |
| 75 | datenUrl: ECHTES_JPEG, |
| 76 | breite: 400, |
| 77 | hoehe: 300, |
| 78 | herkunft: |
| 79 | herkunft === 'georeferenziert' |
| 80 | ? 'Nordrhein-Westfalen · Geobasis NRW · Mitte 50.941278, 6.958281 · Kante 200 m' |
| 81 | : 'katasterauszug.png', |
| 82 | geladenAm: '2026-01-01T10:00:00.000Z', |
| 83 | } |
| 84 | : null, |
| 85 | kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft }, |
| 86 | haltlinien: [ |
| 87 | { |
| 88 | id: 'h1', |
| 89 | name: 'Nord', |
| 90 | art: 'zufahrt', |
| 91 | armId: null, |
| 92 | von: { x: 0, y: 0 }, |
| 93 | bis: { x: 0, y: 100 }, |
| 94 | }, |
| 95 | ], |
| 96 | linien: [ |
| 97 | { |
| 98 | id: 'l1', |
| 99 | name: 'K1', |
| 100 | mode: 'kfz', |
| 101 | movement: 'geradeaus', |
| 102 | breiteMeter: 3, |
| 103 | punkte: [ |
| 104 | { x: 0, y: 50 }, |
| 105 | { x: 200, y: 50 }, |
| 106 | ], |
| 107 | haltlinieId: 'h1', |
| 108 | startT: 0.5, |
| 109 | signalGroupId: null, |
| 110 | }, |
| 111 | ], |
| 112 | }; |
| 113 | } |
| 114 | |
| 115 | function projekt(plan: Lageplan): Project { |
| 116 | return { |
| 117 | ...createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG), |
| 118 | lageplan: plan, |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | function ausdruck(plan: Lageplan, bild: Blattbild | null): Uint8Array { |
| 123 | const p = projekt(plan); |
| 124 | const signalplan = buildSignalPlan(p); |
| 125 | return buildProjectPdf( |
| 126 | p, |
| 127 | signalplan, |
| 128 | validateProject(p, signalplan, STICHTAG), |
| 129 | DEFAULT_PDF_OPTIONS, |
| 130 | STICHTAG, |
| 131 | bild, |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | function blattbild(): Blattbild { |
| 136 | const blatt = lageplanblattVorlage(lageplan('georeferenziert')); |
| 137 | if (blatt === null) throw new Error('Blatt erwartet'); |
| 138 | return { |
| 139 | datenUrl: ECHTES_JPEG, |
| 140 | breite: 100, |
| 141 | hoehe: 40, |
| 142 | zielX: 0, |
| 143 | zielY: 0, |
| 144 | zielBreite: blatt.breite, |
| 145 | zielHoehe: blatt.hoehe, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | describe('Die Unterlage druckt das zweite Blatt nur mit Bild', () => { |
| 150 | it('bleibt ohne Blattbild bei einem Lageplanblatt', () => { |
| 151 | const text = pdfText(ausdruck(lageplan('georeferenziert'), null)); |
| 152 | expect(text).toContain(toWinAnsi('Lageplan der Fahrlinien')); |
| 153 | expect(text).not.toContain(toWinAnsi('über dem Luftbild')); |
| 154 | }); |
| 155 | |
| 156 | it('setzt mit Blattbild ein zweites Blatt darunter', () => { |
| 157 | const text = pdfText(ausdruck(lageplan('georeferenziert'), blattbild())); |
| 158 | expect(text).toContain(toWinAnsi('Lageplan der Fahrlinien')); |
| 159 | expect(text).toContain(toWinAnsi('Derselbe Lageplan über dem Luftbild')); |
| 160 | }); |
| 161 | |
| 162 | it('legt die Bilddaten wirklich in das Dokument', () => { |
| 163 | const roh = new TextDecoder('latin1').decode( |
| 164 | ausdruck(lageplan('georeferenziert'), blattbild()), |
| 165 | ); |
| 166 | expect(roh, 'kein eingebettetes JPEG').toContain('DCTDecode'); |
| 167 | }); |
| 168 | |
| 169 | it('sagt auf dem zweiten Blatt, dass daran nicht gemessen wird', () => { |
| 170 | /* |
| 171 | * DIE GEFAEHRLICHE VERWECHSLUNG. Ein Luftbild sieht nach Messgrundlage |
| 172 | * aus. Die verkleinerte Aufnahme traegt die Lage, nicht die Laenge - |
| 173 | * abgegriffen wird am Massstabsbalken. |
| 174 | */ |
| 175 | const text = pdfText(ausdruck(lageplan('georeferenziert'), blattbild())); |
| 176 | expect(text).toContain(toWinAnsi('GEMESSEN WIRD NICHT AM BILD')); |
| 177 | }); |
| 178 | }); |
| 179 | |
| 180 | describe('Die Verortung steht auf beiden Blaettern', () => { |
| 181 | it('druckt die Herkunft mit Ausschnittsmitte als Fußzeile', () => { |
| 182 | const text = pdfText(ausdruck(lageplan('georeferenziert'), null)); |
| 183 | expect(text).toContain('50.941278'); |
| 184 | }); |
| 185 | |
| 186 | it('setzt den Nordpfeil nur bei einem georeferenzierten Ausschnitt', () => { |
| 187 | const georef = pdfText(ausdruck(lageplan('georeferenziert'), null)); |
| 188 | const gemessen = pdfText(ausdruck(lageplan('gemessen'), null)); |
| 189 | /* |
| 190 | * Bei einem eingelesenen Bild ist die Ausrichtung unbekannt. Der Ausdruck |
| 191 | * sagt das ausdruecklich, statt es dem Leser zu ueberlassen - und setzt |
| 192 | * keinen Pfeil, den niemand nachpruefen kann. |
| 193 | */ |
| 194 | expect(gemessen).toContain(toWinAnsi('ohne Verortung und ohne Nordrichtung')); |
| 195 | expect(georef).not.toContain(toWinAnsi('ohne Nordrichtung')); |
| 196 | }); |
| 197 | |
| 198 | it('laesst das Blatt ganz weg, wo es keine Bildgrundlage gibt', () => { |
| 199 | /* |
| 200 | * Ohne Bild gibt es kein Blatt - `drawPlanungsgrundlage` bricht den |
| 201 | * Abschnitt schon vorher ab, und der Abschnitt selbst prueft es noch |
| 202 | * einmal an der Stelle, an der es gilt. Kein dritter Fusszeilentext also: |
| 203 | * Ein Text fuer einen Fall, den nichts erreicht, sieht aus wie Vorsicht |
| 204 | * und ist eine Luecke im Nachweis. |
| 205 | */ |
| 206 | const text = pdfText(ausdruck(lageplan('gemessen', false), null)); |
| 207 | // Nicht an der Ueberschrift gemessen: Die Bestandteileuebersicht nennt den |
| 208 | // Abschnitt ohnehin. Der Satz hier steht nur unter der Zeichnung. |
| 209 | expect(text).not.toContain(toWinAnsi('MASSGEBEND IST DER MASSSTABSBALKEN')); |
| 210 | expect(text).toContain(toWinAnsi('kein Lageplan und kein Luftbild')); |
| 211 | }); |
| 212 | }); |
| 213 | |
| 214 | // --- Das Verkleinern -------------------------------------------------------- |
| 215 | |
| 216 | describe('Die Groesse der Grafik folgt dem Blatt, nicht der Vorlage', () => { |
| 217 | it('rechnet Blattpunkte mit 200 Punkten je Zoll in Bildpunkte um', () => { |
| 218 | expect(BLATTBILD_DPI).toBe(200); |
| 219 | // 300 Blattpunkte sind 300/72 Zoll, mal 200 sind 833 Bildpunkte. |
| 220 | expect(blattbildGroesse(770, 300)).toEqual({ breite: 2139, hoehe: 833 }); |
| 221 | }); |
| 222 | |
| 223 | it('deckelt eine unsinnig grosse Kante', () => { |
| 224 | expect(blattbildGroesse(100000, 300)!.breite).toBe(4000); |
| 225 | }); |
| 226 | |
| 227 | it('meldet null bei unbrauchbaren Massen', () => { |
| 228 | expect(blattbildGroesse(0, 300)).toBeNull(); |
| 229 | expect(blattbildGroesse(770, Number.NaN)).toBeNull(); |
| 230 | expect(blattbildGroesse(770, 300, 0)).toBeNull(); |
| 231 | }); |
| 232 | |
| 233 | it('zaehlt die Bytes hinter einer Daten-URL, ohne sie zu entschluesseln', () => { |
| 234 | expect(datenBytes('data:image/jpeg;base64,////')).toBe(3); |
| 235 | expect(datenBytes('data:image/jpeg;base64,//8=')).toBe(2); |
| 236 | expect(datenBytes('data:image/jpeg;base64,//==')).toBe(1); |
| 237 | expect(datenBytes('unbrauchbar')).toBe(0); |
| 238 | }); |
| 239 | }); |
| 240 | |
| 241 | interface Vorgang { |
| 242 | readonly art: 'fuellen' | 'zeichnen'; |
| 243 | readonly werte: readonly number[]; |
| 244 | } |
| 245 | |
| 246 | let vorgaenge: Vorgang[] = []; |
| 247 | let stufen: (number | undefined)[] = []; |
| 248 | let geschlossen = 0; |
| 249 | let laenge = 40; |
| 250 | |
| 251 | class Zeichenstift { |
| 252 | imageSmoothingQuality = 'low'; |
| 253 | fillStyle = ''; |
| 254 | fillRect(x: number, y: number, breite: number, hoehe: number): void { |
| 255 | vorgaenge.push({ art: 'fuellen', werte: [x, y, breite, hoehe] }); |
| 256 | } |
| 257 | drawImage(_quelle: unknown, ...werte: number[]): void { |
| 258 | vorgaenge.push({ art: 'zeichnen', werte }); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | beforeEach(() => { |
| 263 | vorgaenge = []; |
| 264 | stufen = []; |
| 265 | geschlossen = 0; |
| 266 | laenge = 40; |
| 267 | vi.stubGlobal('createImageBitmap', () => |
| 268 | Promise.resolve({ |
| 269 | width: 400, |
| 270 | height: 300, |
| 271 | close: (): void => { |
| 272 | geschlossen += 1; |
| 273 | }, |
| 274 | }), |
| 275 | ); |
| 276 | vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( |
| 277 | () => new Zeichenstift() as unknown as CanvasRenderingContext2D, |
| 278 | ); |
| 279 | vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation( |
| 280 | (_typ?: string, stufe?: unknown) => { |
| 281 | stufen.push(stufe as number | undefined); |
| 282 | return `data:image/jpeg;base64,${'A'.repeat(laenge)}`; |
| 283 | }, |
| 284 | ); |
| 285 | }); |
| 286 | |
| 287 | afterEach(() => { |
| 288 | vi.unstubAllGlobals(); |
| 289 | vi.restoreAllMocks(); |
| 290 | }); |
| 291 | |
| 292 | async function vorbereitet(plan: Lageplan = lageplan('georeferenziert')) { |
| 293 | const blatt = lageplanblattVorlage(plan); |
| 294 | if (blatt === null) throw new Error('Blatt erwartet'); |
| 295 | if (plan.bild === null) throw new Error('Bild erwartet'); |
| 296 | return blattbildVorbereiten(plan.bild, blatt); |
| 297 | } |
| 298 | |
| 299 | describe('Das Luftbild wird zugeschnitten und verkleinert', () => { |
| 300 | it('fuellt die Flaeche weiss, BEVOR gezeichnet wird', () => { |
| 301 | /* |
| 302 | * JPEG kennt keinen Alphakanal; eine durchsichtige Flaeche wird nach der |
| 303 | * HTML-Vorgabe auf SCHWARZ komponiert. Ragt die Zeichnung ueber den |
| 304 | * Bildrand hinaus - der Regelfall bei einer Zeichnung am Rand -, waere das |
| 305 | * ein schwarzer Balken neben der Aufnahme. Eine Fuellung NACH dem Zeichnen |
| 306 | * loeschte statt dessen das Bild. |
| 307 | */ |
| 308 | return vorbereitet().then(() => { |
| 309 | expect(vorgaenge.map((v) => v.art)).toEqual(['fuellen', 'zeichnen']); |
| 310 | }); |
| 311 | }); |
| 312 | |
| 313 | it('zeichnet mit Quell- und Zielbereich aus dem Zuschnitt', async () => { |
| 314 | await vorbereitet(); |
| 315 | const zeichnen = vorgaenge.find((v) => v.art === 'zeichnen'); |
| 316 | expect(zeichnen, 'nicht gezeichnet').toBeDefined(); |
| 317 | // Acht Werte heisst: Quellrechteck UND Zielrechteck. Mit vieren waere der |
| 318 | // Zuschnitt gar nicht angewendet. |
| 319 | expect(zeichnen!.werte).toHaveLength(8); |
| 320 | }); |
| 321 | |
| 322 | it('nimmt die beste Verdichtungsstufe, wenn sie schon passt', async () => { |
| 323 | await vorbereitet(); |
| 324 | expect(stufen).toEqual([0.72]); |
| 325 | }); |
| 326 | |
| 327 | it('geht die Stufen abwaerts, bis das Ziel erreicht ist', async () => { |
| 328 | // Eine Daten-URL, die erst unterhalb der Regelstufe klein genug wird. |
| 329 | let ruf = 0; |
| 330 | vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation( |
| 331 | (_typ?: string, stufe?: unknown) => { |
| 332 | stufen.push(stufe as number | undefined); |
| 333 | ruf += 1; |
| 334 | const zeichen = ruf < 3 ? BLATTBILD_ZIELBYTES * 4 : 100; |
| 335 | return `data:image/jpeg;base64,${'A'.repeat(zeichen)}`; |
| 336 | }, |
| 337 | ); |
| 338 | await vorbereitet(); |
| 339 | expect(stufen).toEqual([0.72, 0.6, 0.45]); |
| 340 | }); |
| 341 | |
| 342 | it('gibt auch dann ein Bild aus, wenn keine Stufe das Ziel erreicht', async () => { |
| 343 | /* |
| 344 | * Eine Planunterlage am Erzeugen zu hindern, weil eine Aufnahme sich |
| 345 | * schlecht verdichtet, waere das falsche Verhaeltnis von Anlass und Folge. |
| 346 | */ |
| 347 | laenge = BLATTBILD_ZIELBYTES * 4; |
| 348 | const ergebnis = await vorbereitet(); |
| 349 | expect(ergebnis, 'kein Bild trotz letzter Stufe').not.toBeNull(); |
| 350 | expect(stufen).toEqual([0.72, 0.6, 0.45, 0.32]); |
| 351 | }); |
| 352 | |
| 353 | it('deckt mit dem Ergebnis das ganze Blatt ab - der Zuschnitt steckt darin', async () => { |
| 354 | const blatt = lageplanblattVorlage(lageplan('georeferenziert'))!; |
| 355 | const ergebnis = await vorbereitet(); |
| 356 | expect(ergebnis!.zielX).toBe(0); |
| 357 | expect(ergebnis!.zielY).toBe(0); |
| 358 | expect(ergebnis!.zielBreite).toBe(blatt.breite); |
| 359 | expect(ergebnis!.zielHoehe).toBe(blatt.hoehe); |
| 360 | }); |
| 361 | |
| 362 | it('gibt das entschluesselte Bild wieder frei', async () => { |
| 363 | // Bei bis zu 24 MiB ist das kein Nebenpunkt. |
| 364 | await vorbereitet(); |
| 365 | expect(geschlossen).toBe(1); |
| 366 | }); |
| 367 | |
| 368 | it('meldet null bei einer unlesbaren Daten-URL, statt den Ausdruck zu verhindern', async () => { |
| 369 | const plan = lageplan('georeferenziert'); |
| 370 | const kaputt: Lageplan = { ...plan, bild: { ...plan.bild!, datenUrl: 'kein Bild' } }; |
| 371 | expect(await vorbereitet(kaputt)).toBeNull(); |
| 372 | }); |
| 373 | |
| 374 | it('meldet null, wo es keine Zeichenflaeche gibt', async () => { |
| 375 | vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => null); |
| 376 | expect(await vorbereitet()).toBeNull(); |
| 377 | }); |
| 378 | }); |