import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createArm, createConflict, createEmptyProject, createPhase, createSignalGroup, } from '@/domain/model/factory'; import type { Conflict, Project, SignalGroup } from '@/domain/model/project'; /* * BEFUND 8 (Fassung 5.11.0): Ein langer Signalgruppenname brachte die ganze * PDF-Ausgabe mit RangeError zum Absturz. * * Die Wache in `matrixSatz` verkleinerte bei zu hohem Tabellenkopf nur die Zahl * der Spalten je Ausschnitt und verliess die Schleife bei einer einzigen Spalte * OHNE noch einmal zu pruefen, ob Kopf und Koerperzeile dort ueberhaupt auf ein * Blatt passen. Diesen Fall konnte sie mit ihrem einzigen Hebel gar nicht * erreichen: Die Hoehe der Koerperzeile entsteht aus dem Zeilenkopf, der den * vollen Namen in der begrenzten Namensspalte traegt, und weniger Spalten * machen ihn nicht niedriger. * * Was der Anwender sonst erlebt: Er fuegt in das Namensfeld einer Signalgruppe * einen langen Text ein (das Feld hat keine Laengengrenze) und erzeugt die * Unterlage. jspdf-autotable laeuft in `printFullRow` in die Endlosteilung - * Hunderte Meldungen "minimum height is larger than page height", dann * "RangeError: Maximum call stack size exceeded". Der Pruefbericht meldet dabei * 0 Fehler, `exportBlocked` ist false; es entsteht keine Ausgabe, auch der * Pruefbericht nicht. Genau diesen Einsturz zu verhindern ist die im Kommentar * ueber der Schranke gegebene Zusage. * * Was die Behebung NICHT tun darf: die Zuordnungszeile kuerzen. Sie ist der * Nachweis, mit dem sich ein einzelnes Blatt ohne ein zweites aufloesen laesst * (pdfZwischenzeitenmatrix.test.ts, "loest die Zuordnung der Spaltenkoepfe auf * JEDEM Blatt auf"). Gekuerzt wird der ZEILENKOPF, in dem der Name ohnehin ein * zweites Mal steht. */ const FEST = new Date('2026-01-01T12:00:00Z'); const PAGE_MARGIN = 36; const FOOTER_HEIGHT = 26; /** Unterkante des Satzspiegels, in PDF-Koordinaten (Ursprung unten). */ const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT; /** Grundlinie der Fusszeile - der einzige Text, der darunter stehen darf. */ const FUSSZEILE_Y = PAGE_MARGIN + 6; const NUR_MATRIX: PdfOptions = { ...DEFAULT_PDF_OPTIONS, includeSignalGroups: false, includePhases: false, includePlan: false, includePerformance: false, includeReport: false, includeCalculationDetails: false, includeSources: false, }; /** Das Schlusswort des langen Namens - daran haengt die Gegenprobe. */ const SCHLUSSWORT = 'Schlussmerkmal'; /** Ein Name der gewuenschten Laenge; SignalGroup.name ist nicht begrenzt. */ function langerName(laenge: number): string { let text = 'Kraftfahrzeuge Nordzufahrt Hauptstraße geradeaus und rechts abbiegend '; while (text.length < laenge - SCHLUSSWORT.length - 1) text += 'Zusatzangabe '; return `${text.slice(0, laenge - SCHLUSSWORT.length - 1)} ${SCHLUSSWORT}`; } interface Stueck { readonly blatt: number; readonly y: number; readonly text: string; } 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 textstuecke(bytes: Uint8Array): readonly Stueck[] { const roh = new TextDecoder('latin1').decode(bytes); const raus: Stueck[] = []; let blatt = 0; 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) continue; blatt += 1; 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) { 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, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); } } return raus; } /** Ein Knotenpunkt mit sechs Signalgruppen; eine traegt den langen Namen. */ function projekt(laenge: number): Project { const basis = createEmptyProject('Pruefkreuzung', FEST, 'knotenpunkt'); const arme = ['Nord', 'Ost', 'Süd', 'West'].map((r) => createArm(r, r, 2, 50)); const groups: SignalGroup[] = []; for (let i = 0; i < 6; i += 1) { groups.push( createSignalGroup({ name: i === 2 ? langerName(laenge) : `K${i + 1}`, mode: 'kfz', armId: arme[i % 4]?.id ?? null, index: i, }), ); } const conflicts: Conflict[] = []; for (let i = 0; i < groups.length; i += 1) { for (let j = 0; j < groups.length; j += 1) { if (i === j) continue; conflicts.push({ ...createConflict(groups[i]!.id, groups[j]!.id), clearingDistance: 18 + (i % 7), enteringDistance: 6 + (j % 5), }); } } const p1 = createPhase( 'Phase 1', groups.slice(0, 3).map((g) => g.id), ); const p2 = createPhase( 'Phase 2', groups.slice(3).map((g) => g.id), ); return { ...basis, intersection: { ...basis.intersection, arms: arme }, signalGroups: groups, conflicts, phases: [p1, p2], program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, }; } function unterlage(laenge: number): Uint8Array { const project = projekt(laenge); const plan = buildSignalPlan(project); return buildProjectPdf(project, plan, validateProject(project, plan, FEST), NUR_MATRIX, FEST); } describe('Befund 8 - sehr langer Signalgruppenname in der Matrix', () => { it('haelt die Zuordnung im wiederholten Tabellenkopf, solange sie dorthin passt', () => { // 4.000 Zeichen: Der Zeilenkopf muss schon gekuerzt werden (in der 180 pt // breiten Namensspalte waeren es rund 100 Zeilen), die Zuordnung passt aber // noch in den Kopf - erst der zweite Hebel greift, nicht beide. const stuecke = textstuecke(unterlage(4000)); expect( stuecke.filter((s) => s.text.includes('Nummern der Spaltenköpfe')).length, 'die Zuordnung steht ueber jedem Ausschnitt', ).toBeGreaterThan(0); expect( stuecke.filter((s) => s.text.includes('lässt sich deshalb nicht auf jedem Blatt')).length, 'der Ausweichweg wird hier noch nicht gebraucht', ).toBe(0); }, 180_000); it('setzt die Zuordnung ungekuerzt vor den Ausschnitt, wenn sie hoeher ist als ein Blatt', () => { // 20.000 Zeichen ergeben allein fuer EINE Zuordnungszeile mehr Zeilen, als // ein Blatt traegt - der Hebel "weniger Spalten je Ausschnitt" ist damit // erschoepft. Gekuerzt wird sie trotzdem nicht: Sie ist der Nachweis. const stuecke = textstuecke(unterlage(20000)); expect( stuecke.filter((s) => s.text.includes('lässt sich deshalb nicht auf jedem Blatt')).length, 'der Ausdruck sagt nicht, wo die Zuordnung steht', ).toBeGreaterThan(0); expect( stuecke.filter((s) => s.text.startsWith('Zuordnung der Spaltenköpfe für Ausschnitt')).length, 'die Zuordnung steht nicht vor den Ausschnitten', ).toBeGreaterThan(0); }, 180_000); for (const laenge of [7500, 20000]) { it(`erzeugt die Unterlage bei einem Namen von ${laenge} Zeichen`, () => { const bytes = unterlage(laenge); expect(bytes.byteLength).toBeGreaterThan(0); const stuecke = textstuecke(bytes); // Der volle Name bleibt aufloesbar: Sein Schlusswort steht in der // Zuordnung, nicht nur der Anfang. expect( stuecke.filter((s) => s.text.includes(SCHLUSSWORT)).length, 'das Schlusswort des Namens fehlt im Dokument', ).toBeGreaterThan(0); // Der Zeilenkopf ist gekuerzt - sichtbar, nicht stillschweigend. expect( stuecke.filter((s) => s.text.includes('...')).length, 'die Kuerzung des Zeilenkopfs ist nicht mit Auslassungszeichen gekennzeichnet', ).toBeGreaterThan(0); expect( stuecke.filter((s) => s.text.includes('Die Zeilenköpfe dieser Matrix sind gekürzt')).length, 'die Kuerzung wird im Ausdruck nicht erklaert', ).toBeGreaterThan(0); // Und nichts laeuft in den Fusssteg oder aus dem Blatt. expect( stuecke .filter((s) => s.y < SATZSPIEGEL_UNTEN && Math.abs(s.y - FUSSZEILE_Y) > 0.5) .map((s) => `Blatt ${s.blatt}, y ${s.y.toFixed(2)}: "${s.text.slice(0, 60)}"`), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile', ).toEqual([]); }, 180_000); } });