import { inflateSync } from 'node:zlib'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS, lageplanblattVorlage } from '@/services/export/pdf'; import { BLATTBILD_DPI, BLATTBILD_ZIELBYTES, blattbildGroesse, blattbildVorbereiten, datenBytes, type Blattbild, } from '@/services/export/blattbild'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { toWinAnsi } from '@/render/pdfSurface'; import type { Lageplan } from '@/domain/geometrie/lageplan'; import type { Project } from '@/domain/model/project'; /** * Das zweite Lageplanblatt - derselbe Plan ueber dem Luftbild. * * WOZU (aus dem Betrieb, 11.09.2026): Das gedruckte Lageplanblatt zeigte * Fahrlinien ohne Umgebung und trug keine Angabe zur Verortung. Der Anwender * fragte, wie man damit ueberhaupt sagen soll, wo die Anlage liegt. Die * Antwort war: gar nicht - die Herkunftszeile stand im Abschnitt * "Planungsgrundlage" mehrere Seiten davor. * * WARUM DAS BILD NICHT EINFACH MITGEHT: bis zu 24 MiB je Unterlage. Es wird * auf den gezeigten Ausschnitt zugeschnitten und auf die Aufloesung des * Blattes verkleinert; danach haengt die Groesse an der Blattgroesse und nicht * mehr an der Vorlage. * * jsdom bringt keine Zeichenflaeche mit. Der Stift schreibt hier mit, was in * welcher Reihenfolge angeordnet wurde - dasselbe Verfahren wie bei * `bildGrundfuellung`, und aus demselben Grund: Eine Fuellung NACH dem * Zeichnen loeschte das Bild. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); /** Ein wirkliches, wenn auch winziges JPEG - jsPDF liest die Bilddaten. */ const ECHTES_JPEG = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL' + 'DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAAB' + 'AAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=='; function pdfText(bytes: Uint8Array): string { const roh = new TextDecoder('latin1').decode(bytes); const teile: string[] = []; const suche = /stream\r?\n/g; let treffer: RegExpExecArray | null; while ((treffer = suche.exec(roh)) !== null) { const start = treffer.index + treffer[0].length; const ende = roh.indexOf('endstream', start); if (ende < 0) continue; let inhalt: string; try { inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { continue; } for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); } } return teile.join(' ').replace(/\s+/g, ' '); } function lageplan(herkunft: 'gemessen' | 'georeferenziert', mitBild = true): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: mitBild ? { datenUrl: ECHTES_JPEG, breite: 400, hoehe: 300, herkunft: herkunft === 'georeferenziert' ? 'Nordrhein-Westfalen · Geobasis NRW · Mitte 50.941278, 6.958281 · Kante 200 m' : 'katasterauszug.png', geladenAm: '2026-01-01T10:00:00.000Z', } : null, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft }, haltlinien: [ { id: 'h1', name: 'Nord', art: 'zufahrt', armId: null, von: { x: 0, y: 0 }, bis: { x: 0, y: 100 }, }, ], linien: [ { id: 'l1', name: 'K1', mode: 'kfz', movement: 'geradeaus', breiteMeter: 3, punkte: [ { x: 0, y: 50 }, { x: 200, y: 50 }, ], haltlinieId: 'h1', startT: 0.5, signalGroupId: null, }, ], }; } function projekt(plan: Lageplan): Project { return { ...createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG), lageplan: plan, }; } function ausdruck(plan: Lageplan, bild: Blattbild | null): Uint8Array { const p = projekt(plan); const signalplan = buildSignalPlan(p); return buildProjectPdf( p, signalplan, validateProject(p, signalplan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, bild, ); } function blattbild(): Blattbild { const blatt = lageplanblattVorlage(lageplan('georeferenziert')); if (blatt === null) throw new Error('Blatt erwartet'); return { datenUrl: ECHTES_JPEG, breite: 100, hoehe: 40, zielX: 0, zielY: 0, zielBreite: blatt.breite, zielHoehe: blatt.hoehe, }; } describe('Die Unterlage druckt das zweite Blatt nur mit Bild', () => { it('bleibt ohne Blattbild bei einem Lageplanblatt', () => { const text = pdfText(ausdruck(lageplan('georeferenziert'), null)); expect(text).toContain(toWinAnsi('Lageplan der Fahrlinien')); expect(text).not.toContain(toWinAnsi('über dem Luftbild')); }); it('setzt mit Blattbild ein zweites Blatt darunter', () => { const text = pdfText(ausdruck(lageplan('georeferenziert'), blattbild())); expect(text).toContain(toWinAnsi('Lageplan der Fahrlinien')); expect(text).toContain(toWinAnsi('Derselbe Lageplan über dem Luftbild')); }); it('legt die Bilddaten wirklich in das Dokument', () => { const roh = new TextDecoder('latin1').decode( ausdruck(lageplan('georeferenziert'), blattbild()), ); expect(roh, 'kein eingebettetes JPEG').toContain('DCTDecode'); }); it('sagt auf dem zweiten Blatt, dass daran nicht gemessen wird', () => { /* * DIE GEFAEHRLICHE VERWECHSLUNG. Ein Luftbild sieht nach Messgrundlage * aus. Die verkleinerte Aufnahme traegt die Lage, nicht die Laenge - * abgegriffen wird am Massstabsbalken. */ const text = pdfText(ausdruck(lageplan('georeferenziert'), blattbild())); expect(text).toContain(toWinAnsi('GEMESSEN WIRD NICHT AM BILD')); }); }); describe('Die Verortung steht auf beiden Blaettern', () => { it('druckt die Herkunft mit Ausschnittsmitte als Fußzeile', () => { const text = pdfText(ausdruck(lageplan('georeferenziert'), null)); expect(text).toContain('50.941278'); }); it('setzt den Nordpfeil nur bei einem georeferenzierten Ausschnitt', () => { const georef = pdfText(ausdruck(lageplan('georeferenziert'), null)); const gemessen = pdfText(ausdruck(lageplan('gemessen'), null)); /* * Bei einem eingelesenen Bild ist die Ausrichtung unbekannt. Der Ausdruck * sagt das ausdruecklich, statt es dem Leser zu ueberlassen - und setzt * keinen Pfeil, den niemand nachpruefen kann. */ expect(gemessen).toContain(toWinAnsi('ohne Verortung und ohne Nordrichtung')); expect(georef).not.toContain(toWinAnsi('ohne Nordrichtung')); }); it('laesst das Blatt ganz weg, wo es keine Bildgrundlage gibt', () => { /* * Ohne Bild gibt es kein Blatt - `drawPlanungsgrundlage` bricht den * Abschnitt schon vorher ab, und der Abschnitt selbst prueft es noch * einmal an der Stelle, an der es gilt. Kein dritter Fusszeilentext also: * Ein Text fuer einen Fall, den nichts erreicht, sieht aus wie Vorsicht * und ist eine Luecke im Nachweis. */ const text = pdfText(ausdruck(lageplan('gemessen', false), null)); // Nicht an der Ueberschrift gemessen: Die Bestandteileuebersicht nennt den // Abschnitt ohnehin. Der Satz hier steht nur unter der Zeichnung. expect(text).not.toContain(toWinAnsi('MASSGEBEND IST DER MASSSTABSBALKEN')); expect(text).toContain(toWinAnsi('kein Lageplan und kein Luftbild')); }); }); // --- Das Verkleinern -------------------------------------------------------- describe('Die Groesse der Grafik folgt dem Blatt, nicht der Vorlage', () => { it('rechnet Blattpunkte mit 200 Punkten je Zoll in Bildpunkte um', () => { expect(BLATTBILD_DPI).toBe(200); // 300 Blattpunkte sind 300/72 Zoll, mal 200 sind 833 Bildpunkte. expect(blattbildGroesse(770, 300)).toEqual({ breite: 2139, hoehe: 833 }); }); it('deckelt eine unsinnig grosse Kante', () => { expect(blattbildGroesse(100000, 300)!.breite).toBe(4000); }); it('meldet null bei unbrauchbaren Massen', () => { expect(blattbildGroesse(0, 300)).toBeNull(); expect(blattbildGroesse(770, Number.NaN)).toBeNull(); expect(blattbildGroesse(770, 300, 0)).toBeNull(); }); it('zaehlt die Bytes hinter einer Daten-URL, ohne sie zu entschluesseln', () => { expect(datenBytes('data:image/jpeg;base64,////')).toBe(3); expect(datenBytes('data:image/jpeg;base64,//8=')).toBe(2); expect(datenBytes('data:image/jpeg;base64,//==')).toBe(1); expect(datenBytes('unbrauchbar')).toBe(0); }); }); interface Vorgang { readonly art: 'fuellen' | 'zeichnen'; readonly werte: readonly number[]; } let vorgaenge: Vorgang[] = []; let stufen: (number | undefined)[] = []; let geschlossen = 0; let laenge = 40; class Zeichenstift { imageSmoothingQuality = 'low'; fillStyle = ''; fillRect(x: number, y: number, breite: number, hoehe: number): void { vorgaenge.push({ art: 'fuellen', werte: [x, y, breite, hoehe] }); } drawImage(_quelle: unknown, ...werte: number[]): void { vorgaenge.push({ art: 'zeichnen', werte }); } } beforeEach(() => { vorgaenge = []; stufen = []; geschlossen = 0; laenge = 40; vi.stubGlobal('createImageBitmap', () => Promise.resolve({ width: 400, height: 300, close: (): void => { geschlossen += 1; }, }), ); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( () => new Zeichenstift() as unknown as CanvasRenderingContext2D, ); vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation( (_typ?: string, stufe?: unknown) => { stufen.push(stufe as number | undefined); return `data:image/jpeg;base64,${'A'.repeat(laenge)}`; }, ); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); async function vorbereitet(plan: Lageplan = lageplan('georeferenziert')) { const blatt = lageplanblattVorlage(plan); if (blatt === null) throw new Error('Blatt erwartet'); if (plan.bild === null) throw new Error('Bild erwartet'); return blattbildVorbereiten(plan.bild, blatt); } describe('Das Luftbild wird zugeschnitten und verkleinert', () => { it('fuellt die Flaeche weiss, BEVOR gezeichnet wird', () => { /* * JPEG kennt keinen Alphakanal; eine durchsichtige Flaeche wird nach der * HTML-Vorgabe auf SCHWARZ komponiert. Ragt die Zeichnung ueber den * Bildrand hinaus - der Regelfall bei einer Zeichnung am Rand -, waere das * ein schwarzer Balken neben der Aufnahme. Eine Fuellung NACH dem Zeichnen * loeschte statt dessen das Bild. */ return vorbereitet().then(() => { expect(vorgaenge.map((v) => v.art)).toEqual(['fuellen', 'zeichnen']); }); }); it('zeichnet mit Quell- und Zielbereich aus dem Zuschnitt', async () => { await vorbereitet(); const zeichnen = vorgaenge.find((v) => v.art === 'zeichnen'); expect(zeichnen, 'nicht gezeichnet').toBeDefined(); // Acht Werte heisst: Quellrechteck UND Zielrechteck. Mit vieren waere der // Zuschnitt gar nicht angewendet. expect(zeichnen!.werte).toHaveLength(8); }); it('nimmt die beste Verdichtungsstufe, wenn sie schon passt', async () => { await vorbereitet(); expect(stufen).toEqual([0.72]); }); it('geht die Stufen abwaerts, bis das Ziel erreicht ist', async () => { // Eine Daten-URL, die erst unterhalb der Regelstufe klein genug wird. let ruf = 0; vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation( (_typ?: string, stufe?: unknown) => { stufen.push(stufe as number | undefined); ruf += 1; const zeichen = ruf < 3 ? BLATTBILD_ZIELBYTES * 4 : 100; return `data:image/jpeg;base64,${'A'.repeat(zeichen)}`; }, ); await vorbereitet(); expect(stufen).toEqual([0.72, 0.6, 0.45]); }); it('gibt auch dann ein Bild aus, wenn keine Stufe das Ziel erreicht', async () => { /* * Eine Planunterlage am Erzeugen zu hindern, weil eine Aufnahme sich * schlecht verdichtet, waere das falsche Verhaeltnis von Anlass und Folge. */ laenge = BLATTBILD_ZIELBYTES * 4; const ergebnis = await vorbereitet(); expect(ergebnis, 'kein Bild trotz letzter Stufe').not.toBeNull(); expect(stufen).toEqual([0.72, 0.6, 0.45, 0.32]); }); it('deckt mit dem Ergebnis das ganze Blatt ab - der Zuschnitt steckt darin', async () => { const blatt = lageplanblattVorlage(lageplan('georeferenziert'))!; const ergebnis = await vorbereitet(); expect(ergebnis!.zielX).toBe(0); expect(ergebnis!.zielY).toBe(0); expect(ergebnis!.zielBreite).toBe(blatt.breite); expect(ergebnis!.zielHoehe).toBe(blatt.hoehe); }); it('gibt das entschluesselte Bild wieder frei', async () => { // Bei bis zu 24 MiB ist das kein Nebenpunkt. await vorbereitet(); expect(geschlossen).toBe(1); }); it('meldet null bei einer unlesbaren Daten-URL, statt den Ausdruck zu verhindern', async () => { const plan = lageplan('georeferenziert'); const kaputt: Lageplan = { ...plan, bild: { ...plan.bild!, datenUrl: 'kein Bild' } }; expect(await vorbereitet(kaputt)).toBeNull(); }); it('meldet null, wo es keine Zeichenflaeche gibt', async () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => null); expect(await vorbereitet()).toBeNull(); }); });