import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /* * BEFUND 6 (Fassung 5.11.0): Der Kasten "Ergebnis der Pruefung" wurde ohne * Umbruchpruefung an context.cursor gesetzt. * * Was der Anwender sonst erlebt: Er traegt in der Ansicht "Projekt" eine lange * Projektbezeichnung ein (das Feld hat keine Laengengrenze; auf dem Deckblatt * wird sie bewusst umbrochen statt gekuerzt). Die beiden Deckblatttabellen * ruecken damit nach unten, und der 52 pt hohe Ergebniskasten faellt in den * Fusssteg - erst die farbige Flaeche, dann der Vorbehaltssatz "Die * rechnerische Pruefung ersetzt nicht ... keine verkehrsbehoerdliche Anordnung * nach Paragraf 45 StVO", und schliesslich fehlt er im Ausdruck ganz. * * Der Kasten traegt zugleich die einzige Stelle des Dokuments, an der * report.errorCount steht. Deckblatttitel (Befund 43) und Bemerkung * (Befund 18) sind fuer genau diesen Fall gemessen gesetzt worden, der Block * dazwischen nicht. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const PAGE_MARGIN = 36; const FOOTER_HEIGHT = 26; /** Untere Kante des Satzspiegels, von der Blattunterkante aus gemessen. */ const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT; /** Hoehe des Ergebniskastens - dieselbe Zahl wie in pdf.ts. */ const KASTENHOEHE = 52; /** Die Texte des Kastens beginnen 10 pt rechts des Satzspiegelrands. */ const KASTEN_X = PAGE_MARGIN + 10; /** Breite des Satzspiegels auf A4 quer - so breit ist die Flaeche des Kastens. */ const BREITE_SATZSPIEGEL = 841.89 - 2 * PAGE_MARGIN; /** Abstand zwischen der Unterkante des Kastens und dem, was folgt. */ const KASTEN_NACHLAUF = 16; const BAUSTEIN = 'Knotenpunkt Hauptstraße / Bahnhofstraße Ausbaustufe '; /** Eine Projektbezeichnung der gewuenschten Laenge - das Feld ist unbegrenzt. */ function bezeichnung(laenge: number): string { let text = ''; while (text.length < laenge) text += BAUSTEIN; return text.slice(0, laenge).trim(); } interface Stueck { readonly blatt: number; readonly x: number; readonly y: number; readonly text: string; } interface Flaeche { readonly blatt: number; /** Untere Kante, von der Blattunterkante aus - jsPDF setzt die Hoehe negativ. */ readonly unterkante: number; readonly breite: number; readonly hoehe: number; } function entpacke(roh: string, von: number): string | null { const anfang = roh.indexOf('stream', von); if (anfang < 0) return null; const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7); const ende = roh.indexOf('endstream', start); if (ende < 0) return null; try { return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { return null; } } function seiteninhalte(bytes: Uint8Array): readonly string[] { const roh = new TextDecoder('latin1').decode(bytes); const raus: string[] = []; for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) { const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`); const inhalt = objekt < 0 ? null : entpacke(roh, objekt); if (inhalt !== null) raus.push(inhalt); } return raus; } function textstuecke(bytes: Uint8Array): readonly Stueck[] { const raus: Stueck[] = []; let blatt = 0; for (const inhalt of seiteninhalte(bytes)) { blatt += 1; let x = 0; let y = 0; let abstand = 0; for (const roheZeile of inhalt.split('\n')) { const zeile = roheZeile.trim(); const tl = /^(-?[\d.]+) TL$/.exec(zeile); if (tl) { abstand = Number(tl[1]); continue; } const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); if (td) { x = Number(td[1]); y = Number(td[2]); continue; } let rest = zeile; while (rest.startsWith('T*')) { y -= abstand; rest = rest.slice(2).trim(); } const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest); if (tj) raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); } } return raus; } /** Die gefuellten Rechtecke der Datei - jsPDF setzt sie als "x y b h re". */ function flaechen(bytes: Uint8Array): readonly Flaeche[] { const raus: Flaeche[] = []; let blatt = 0; for (const inhalt of seiteninhalte(bytes)) { blatt += 1; for (const treffer of inhalt.matchAll(/(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) re\b/g)) { raus.push({ blatt, unterkante: Number(treffer[2]) + Number(treffer[4]), breite: Number(treffer[3]), hoehe: Math.abs(Number(treffer[4])), }); } } return raus; } function unterlage(name: string, bemerkung = ''): Uint8Array { const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); const projekt: Project = { ...basis, meta: { ...basis.meta, name, description: bemerkung }, }; const plan = buildSignalPlan(projekt); return buildProjectPdf( projekt, plan, validateProject(projekt, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, ); } /** Die drei Texte des Ergebniskastens - sie stehen als einzige bei x = 46. */ function kastentexte(bytes: Uint8Array): readonly Stueck[] { return textstuecke(bytes).filter((s) => Math.abs(s.x - KASTEN_X) < 0.01); } describe('Befund 6 - Ergebniskasten des Deckblatts', () => { for (const laenge of [400, 485, 809]) { it(`haelt den Ergebniskasten bei ${laenge} Zeichen Bezeichnung im Satzspiegel`, () => { const bytes = unterlage(bezeichnung(laenge)); const texte = kastentexte(bytes); expect( texte.map((s) => s.text), 'Vorbedingung: Ueberschrift, Pruefergebnis und Vorbehalt stehen im Dokument', ).toHaveLength(3); expect(texte[0]!.text).toBe('Ergebnis der Prüfung'); expect(texte[2]!.text).toContain('Paragraf 45 StVO'); for (const s of texte) { expect( s.y, `"${s.text}" steht auf Blatt ${s.blatt} bei y ${s.y.toFixed(2)}`, ).toBeGreaterThanOrEqual(SATZSPIEGEL_UNTEN); } const kasten = flaechen(bytes).filter((f) => Math.abs(f.hoehe - KASTENHOEHE) < 0.01); expect(kasten, 'Vorbedingung: die farbige Flaeche des Kastens').toHaveLength(1); expect( kasten[0]!.unterkante, `Unterkante der farbigen Flaeche bei ${kasten[0]!.unterkante.toFixed(2)}`, ).toBeGreaterThanOrEqual(SATZSPIEGEL_UNTEN); }); } it('laesst den Kasten bei kurzer Bezeichnung auf dem Deckblatt stehen', () => { const texte = kastentexte(unterlage('Musterkreuzung')); expect(texte).toHaveLength(3); for (const s of texte) expect(s.blatt, `"${s.text}"`).toBe(1); }); /* * NACHTRAG ZU BEFUND 6: Die Kastenhoehe wird an drei Stellen gebraucht - * zum Reservieren des Platzes, zum Zeichnen der Flaeche und zum * Weiterruecken auf den Bemerkungsblock darunter. Die dritte stand als feste * 68 (= 52 + 16) im Quelltext: Waechst die Kastenhoehe, wandern Reserve und * Flaeche mit, die Ueberschrift "Bemerkungen" aber nicht - sie rutscht in den * Kasten hinein. * * Gemessen wird deshalb nicht die Zahl, sondern der ABSTAND: Die Ueberschrift * steht 16 pt unter der Unterkante der farbigen Flaeche, welche Hoehe diese * auch hat. Gegen eine geaenderte Kastenhoehe ist der Fall rot, solange die * dritte Stelle nicht mitrechnet. */ it('setzt die Ueberschrift "Bemerkungen" 16 pt unter die Unterkante des Kastens', () => { // Mit langer Bezeichnung: Dann rutscht der Kasten geschlossen auf ein // frisches Blatt, und die Bemerkung steht unmittelbar darunter - beide // Groessen sind damit auf demselben Blatt messbar. Bei kurzer Bezeichnung // steht der Kasten am Blattfuss, und die Ueberschrift beginnt ohnehin ein // neues Blatt. const bytes = unterlage( bezeichnung(485), 'Abstimmung mit der Straßenverkehrsbehörde steht aus.', ); const ueberschrift = textstuecke(bytes).filter((s) => s.text === 'Bemerkungen'); expect(ueberschrift, 'Vorbedingung: die Bemerkung wird gesetzt').toHaveLength(1); // Der Kasten ist das einzige gefuellte Rechteck ueber die volle Breite des // Satzspiegels; die Tabellenzellen darueber sind schmaler. const kasten = flaechen(bytes).filter( (f) => f.blatt === ueberschrift[0]!.blatt && Math.abs(f.breite - BREITE_SATZSPIEGEL) < 0.01, ); expect(kasten, 'Vorbedingung: Kasten und Ueberschrift stehen auf demselben Blatt').toHaveLength( 1, ); expect( Number((kasten[0]!.unterkante - ueberschrift[0]!.y).toFixed(2)), `Kastenunterkante ${kasten[0]!.unterkante.toFixed(2)}, Überschrift bei ` + `${ueberschrift[0]!.y.toFixed(2)} - der Abstand haengt an der Kastenhoehe`, ).toBe(KASTEN_NACHLAUF); }); });