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 type { Project } from '@/domain/model/project'; /* * BEFUND 5 (Fassung 5.11.0): Die linke Fusszeile ging ohne Breitenbegrenzung an * doc.text. * * Was der Anwender sonst erlebt: Er traegt in der Ansicht "Projekt" unter * "Bearbeiter" die uebliche Buerobezeichnung statt eines Personennamens ein - * das Feld hat keine Laengengrenze. Auf JEDEM Blatt der Unterlage schiebt sich * die Zeile "Ausgedruckt am ... Bearbeiter: ..." dann unter die rechtsbuendige * Angabe "Seite x von y" und laeuft schliesslich ueber die Blattkante hinaus. * Unlesbar wird dabei ausgerechnet die Angabe, die die Vollstaendigkeit des * Schriftstuecks belegt. * * Beide KOPFzeilen sind seit Fassung 5.9.0 (Befund 43) auf `gekuerztAufBreite` * umgestellt; die Fusszeile derselben Schleife ging leer aus. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const PAGE_MARGIN = 36; const BLATTBREITE = 841.89; /** Rechter Rand des Satzspiegels. */ const SATZSPIEGEL_RECHTS = BLATTBREITE - PAGE_MARGIN; /** Grundlinie der Fusszeile, von der Blattunterkante aus gemessen. */ const FUSSZEILE_Y = PAGE_MARGIN + 6; /** * Eine Buerobezeichnung, wie sie in einer Anordnungsunterlage vorkommt - * 192 Zeichen. Ab 146 Zeichen ueberdeckt sie die Seitenangabe, ab 171 laeuft * sie ueber die Blattkante. */ const BUERO = 'Dipl.-Ing. Maximilian von Wertheim-Schoenbrunn, Ingenieurbuero fuer ' + 'Verkehrsplanung und Verkehrstechnik GmbH & Co. KG, Musterstadt am Berge, ' + 'Abteilung Lichtsignalanlagen und Verkehrsmanagement'; 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): number { const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' }); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); return doc.getTextWidth(text); } function unterlage(planner: string): Uint8Array { const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); const projekt: Project = { ...basis, meta: { ...basis.meta, planner } }; const plan = buildSignalPlan(projekt); return buildProjectPdf( projekt, plan, validateProject(projekt, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG, ); } /** Die beiden Fusszeilentexte je Blatt, von links nach rechts. */ function fusszeilen(bytes: Uint8Array): Map { const je = new Map(); for (const s of textstuecke(bytes)) { if (Math.abs(s.y - FUSSZEILE_Y) > 0.5) continue; const bisher = je.get(s.blatt) ?? []; bisher.push(s); je.set(s.blatt, bisher); } for (const [blatt, stuecke] of je) je.set( blatt, [...stuecke].sort((a, b) => a.x - b.x), ); return je; } describe('Befund 5 - Bearbeiterangabe in der Fusszeile', () => { it('haelt die Bearbeiterangabe von der Seitenangabe und vom Satzspiegelrand frei', () => { const je = fusszeilen(unterlage(BUERO)); expect(je.size, 'Vorbedingung: mehrere Blaetter mit Fusszeile').toBeGreaterThan(3); for (const [blatt, stuecke] of je) { expect(stuecke.length, `Blatt ${blatt}: Bearbeiterangabe und Seitenangabe`).toBe(2); const links = stuecke[0]!; const seite = stuecke[1]!; expect(seite.text, `Blatt ${blatt}: rechts steht die Seitenangabe`).toMatch(/^Seite \d+ von/); const ende = links.x + breite(links.text); expect( ende, `Blatt ${blatt}: "${links.text}" endet bei ${ende.toFixed(2)}, ` + `"${seite.text}" beginnt bei ${seite.x.toFixed(2)}`, ).toBeLessThanOrEqual(seite.x); expect(ende, `Blatt ${blatt}: endet bei ${ende.toFixed(2)}`).toBeLessThanOrEqual( SATZSPIEGEL_RECHTS, ); } }); it('laesst eine kurze Bearbeiterangabe unveraendert stehen', () => { const je = fusszeilen(unterlage('O. Willerding')); const erstes = je.get(1) ?? []; expect(erstes.length).toBe(2); expect(erstes[0]!.text).toContain('Bearbeiter: O. Willerding'); expect(erstes[0]!.text, 'kurze Angaben werden nicht gekuerzt').not.toContain('...'); }); });