import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { jsPDF } from 'jspdf'; 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 { toWinAnsi } from '@/render/pdfSurface'; import type { Project } from '@/domain/model/project'; /* * Befund 43: Kopfzeile und Deckblatttitel druckten die Projektbezeichnung ohne * jede Breitenbegrenzung. * * Was der Anwender sonst erlebt: Er traegt in der Ansicht "Projekt" eine * uebliche lange Bezeichnung ein ("Erneuerung der Lichtsignalanlage * Bundesstrasse ... Planfall 2 (Variante Nord)", 124 Zeichen; das Feld hat * keine Laengengrenze) und erzeugt die Unterlage. In der Kopfzeile JEDER Seite * laeuft der Name in die rechts stehende Grundlagenangabe hinein - beide sind * an der Ueberlappung unlesbar, gerade bei einstreifiger Verkehrsfuehrung, wo * die Kopfzeile das Verhaeltnis von RSA 21 und RiLSA nennt. Auf dem Deckblatt * laeuft derselbe Name in 15 pt ueber die Blattkante und ist dort * abgeschnitten. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const PAGE_MARGIN = 36; const FOOTER_HEIGHT = 26; const BLATTBREITE = 841.89; /** Rechter Rand des Satzspiegels. */ const SATZSPIEGEL_RECHTS = BLATTBREITE - PAGE_MARGIN; /** Grundlinie der Kopfzeile mit dem Projektnamen, von der Blattunterkante. */ const KOPFZEILE_Y = 547.28; /** Untere Kante des Satzspiegels, von der Blattunterkante aus gemessen. */ const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT; /** Grundlinie der Fusszeile - der einzige Text, der darunter stehen darf. */ const FUSSZEILE_Y = PAGE_MARGIN + 6; const LANGER_NAME = 'Erneuerung der Lichtsignalanlage Bundesstrasse 51 / Kreisstrasse 14, ' + 'Ortsdurchfahrt Musterhausen, Planfall 2 (Variante Nord)'; /** * Rund 3.000 Zeichen - der Deckblatttitel fuellt damit gut 30 Zeilen, das * Deckblatt traegt aber nur 23. Das Schlusswort steht am Ende, weil die * Kopfzeile den Namen kuerzt und ihn deshalb nie enthaelt. */ const SEHR_LANGER_NAME = `${Array.from( { length: 70 }, (_, i) => `Bauabschnitt ${i + 1} Ortsdurchfahrt Musterhausen`, ).join(' ')} Schlussmerkmal`; interface Stueck { readonly blatt: number; readonly x: 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 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; } /** Gemessene Breite - unabhaengig vom Pruefling, mit derselben Schrift. */ function breite(text: string, groesse: number, stil: 'normal' | 'bold'): number { const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' }); doc.setFont('helvetica', stil); doc.setFontSize(groesse); return doc.getTextWidth(text); } function unterlage(anlagenart: Project['anlagenart'], name = LANGER_NAME): Uint8Array { const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); const projekt: Project = { ...basis, anlagenart, meta: { ...basis.meta, name }, }; const plan = buildSignalPlan(projekt); return buildProjectPdf( projekt, plan, validateProject(projekt, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, ); } describe('Lange Projektbezeichnung im Ausdruck', () => { for (const anlagenart of ['knotenpunkt', 'einstreifig'] as const) { it(`haelt bei "${anlagenart}" den Namen der Kopfzeile von der Grundlagenangabe frei`, () => { const stuecke = textstuecke(unterlage(anlagenart)); const kopf = stuecke.filter((s) => Math.abs(s.y - KOPFZEILE_Y) < 0.5); const blaetter = new Set(kopf.map((s) => s.blatt)); expect(blaetter.size, 'Vorbedingung: mehrere Blaetter mit Kopfzeile').toBeGreaterThan(3); for (const blatt of blaetter) { const aufBlatt = [...kopf].filter((s) => s.blatt === blatt).sort((a, b) => a.x - b.x); expect(aufBlatt.length, `Blatt ${blatt}: Name und Grundlagenangabe`).toBe(2); const name = aufBlatt[0]!; const grundlage = aufBlatt[1]!; const ende = name.x + breite(name.text, 9, 'bold'); expect( ende, `Blatt ${blatt}: "${name.text}" endet bei ${ende.toFixed(2)}, ` + `"${grundlage.text}" beginnt bei ${grundlage.x.toFixed(2)}`, ).toBeLessThanOrEqual(grundlage.x); } }); } it('haelt die zweite Kopfzeile innerhalb des Satzspiegels', () => { const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); const projekt: Project = { ...basis, meta: { ...basis.meta, name: 'Kurz', projectNumber: LANGER_NAME, location: LANGER_NAME, variant: LANGER_NAME, }, }; const plan = buildSignalPlan(projekt); const stuecke = textstuecke( buildProjectPdf( projekt, plan, validateProject(projekt, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, ), ); const zweite = stuecke.filter((s) => Math.abs(s.y - 533.28) < 0.5 && s.x === PAGE_MARGIN); expect(zweite.length, 'Vorbedingung: die zweite Kopfzeile steht im Dokument').toBeGreaterThan( 3, ); for (const s of zweite) { const ende = s.x + breite(s.text, 9, 'normal'); expect(ende, `endet bei ${ende.toFixed(2)}: "${s.text}"`).toBeLessThanOrEqual( SATZSPIEGEL_RECHTS, ); } }); it('setzt den Deckblatttitel vollstaendig und innerhalb des Blattes', () => { const stuecke = textstuecke(unterlage('einstreifig')).filter((s) => s.blatt === 1); // Der Titel steht unter der Ueberschrift "Signalzeitenplan" und ueber der // ersten Tabelle - alles oberhalb der Kopfzeile scheidet aus. const titel = stuecke.filter( (s) => s.y < 490 && s.y > 400 && s.x === PAGE_MARGIN && s.text !== 'Signalzeitenplan', ); expect(titel.length, 'Vorbedingung: der Deckblatttitel steht im Dokument').toBeGreaterThan(0); // Kein Bestandteil laeuft ueber den Satzspiegel hinaus. for (const s of titel) { const ende = s.x + breite(s.text, 15, 'bold'); expect(ende, `endet bei ${ende.toFixed(2)}: "${s.text}"`).toBeLessThanOrEqual( SATZSPIEGEL_RECHTS, ); } // Und der Name steht vollstaendig da - auf dem Deckblatt wird umbrochen, // nicht gekuerzt. expect(titel.map((s) => s.text).join(' ')).toBe(toWinAnsi(LANGER_NAME)); }); /* * Nachtrag zu Befund 43: Die Breite war gemessen, die Hoehe nicht. Der * Titel wurde Zeile fuer Zeile nach unten gesetzt, ohne Folgeblatt. * * Was der Anwender sonst erlebt: Er traegt - etwa aus einer eingelesenen * Altdatei uebernommen - eine sehr lange Bezeichnung ein. Auf dem Deckblatt * laeuft der Titel dann in den Fusssteg, ueberdruckt die Fusszeile und faellt * ab rund 2.700 Zeichen ganz aus dem Blatt - ohne jeden Hinweis. */ it('setzt einen sehr langen Deckblatttitel auf Folgeblaettern fort statt unter den Satzspiegel', () => { const stuecke = textstuecke(unterlage('knotenpunkt', SEHR_LANGER_NAME)); const imFusssteg = stuecke.filter( (s) => s.y < SATZSPIEGEL_UNTEN && Math.abs(s.y - FUSSZEILE_Y) > 0.5, ); expect( imFusssteg.map((s) => `Blatt ${s.blatt}, y ${s.y.toFixed(2)}: "${s.text}"`), 'kein Text unterhalb des Satzspiegels ausser der Fusszeile', ).toEqual([]); // Gegenprobe: Die letzte Titelzeile steht im Dokument - auf dem Deckblatt // wird umbrochen, nicht gekuerzt. const schluss = stuecke.filter((s) => s.text.includes('Schlussmerkmal')); expect(schluss.length, 'die letzte Titelzeile fehlt im Dokument').toBe(1); }); });