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 { toWinAnsi } from '@/render/pdfSurface'; import type { Project, SignalGroup } from '@/domain/model/project'; /* * Sofortmassnahmen an den erzeugten Unterlagen (Befunde L10, M12, S1/D-3, * M5/D-6, D-5). * * WAS DIESE PRUEFUNG NICHT BEHAUPTET: Sie weist nicht nach, dass die erzeugten * Unterlagen barrierefrei sind. Sie sind es nicht - sie sind ungetaggt, und mit * jsPDF 2.5.2 ist daran nichts zu aendern. Geprueft wird ausschliesslich, was * im Bestand zugesagt wurde: Titelausweisung, Lesezeichen, der Verweis auf die * gleichwertige Textfassung, die Fussnote zu den unbeschrifteten Abschnitten, * die Erklaerung zur Barrierefreiheit im Dokument - und dass die Zeichnung * samt Legende auf der Seite bleibt. * * Der letzte Punkt ist der einzige mit einer Geometrie: Befund M12 war nicht * daran zu erkennen, dass etwas fehlt, sondern daran, dass gezeichnet wurde, * wo keine Seite mehr war. Ein Prueffall, der nur die Legendentexte im * Dokument sucht, geht auch gegen den Altstand durch - die Texte SIND dort, * nur ausserhalb des Papiers. Deshalb wird hier gemessen. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const PAGE_MARGIN = 36; const FOOTER_HEIGHT = 26; /** Unterkante des bedruckbaren Bereichs, in PDF-Koordinaten (Ursprung unten). */ const UNTERKANTE = PAGE_MARGIN + FOOTER_HEIGHT; /** So, wie der Text im PDF ankommt - Anfuehrungszeichen und Striche gewandelt. */ function gedruckt(text: string): string { return toWinAnsi(text); } interface Seite { readonly nummer: number; readonly text: string; /** Unterster Rand aller gezeichneten Rechtecke dieser Seite. */ readonly unterster: 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; } } /** * Seiten des Dokuments mit ihrem sichtbaren Text und ihrer Zeichengeometrie. * * Die Inhaltsstroeme sind komprimiert; eine Pruefung, die nur die Rohbytes * durchsucht, findet keinen einzigen Buchstaben und prueft nichts. */ function seiten(bytes: Uint8Array): readonly Seite[] { const roh = new TextDecoder('latin1').decode(bytes); const raus: Seite[] = []; 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; const stuecke: string[] = []; for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { stuecke.push(t[1]!.replace(/\\([()\\])/g, '$1')); } let unterster = Number.POSITIVE_INFINITY; for (const t of inhalt.matchAll(/(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) re/g)) { unterster = Math.min(unterster, Number(t[2])); } raus.push({ nummer: raus.length + 1, text: stuecke.join(' ').replace(/\s+/g, ' '), unterster, }); } return raus; } function volltext(bytes: Uint8Array): string { return seiten(bytes) .map((s) => s.text) .join(' '); } /** Klartextanteile der Datei - Katalog, Lesezeichen, Anzeigevorgaben. */ function struktur(bytes: Uint8Array): string { return new TextDecoder('latin1').decode(bytes); } function mitSignalgruppen(anzahl: number): Project { const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG); const gruppen: SignalGroup[] = []; for (let i = 0; i < anzahl; i += 1) { const vorlage = basis.signalGroups[i % basis.signalGroups.length]!; gruppen.push({ ...vorlage, id: `sg${i + 1}`, name: `K${i + 1}` }); } return { ...basis, signalGroups: gruppen }; } /** * Dieselbe Anlage mit Namen, wie sie in einer Anordnungsunterlage stehen. * * Die Namen sind kein Kunstgriff: Wer eine Zufahrt samt Fahrbeziehung und * Furtlage in den Signalgruppennamen schreibt, kommt muehelos auf diese Laenge - * und die Blattangabe des Verweisabsatzes nennt den ersten und den letzten. */ function mitLangenNamen(anzahl: number, teile: number): Project { const basis = mitSignalgruppen(anzahl); const zusatz = [ 'Zufahrt Nord, Hauptstraße stadteinwärts, Geradeaus und Rechtsabbieger gemeinsam geführt', 'Zufahrt Süd, Bahnhofstraße Richtung Innenstadt, Linksabbieger mit eigener Signalgruppe', 'Furt West mit Mittelinsel, gemeinsame Führung von Fuß- und Radverkehr, Blindenzusatz', ]; return { ...basis, signalGroups: basis.signalGroups.map((g) => ({ ...g, name: `${g.name} ${zusatz.slice(0, teile).join(', ')}`, })), }; } function ausdruck(project: Project): Uint8Array { const plan = buildSignalPlan(project); const report = validateProject(project, plan, STICHTAG); return buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, STICHTAG); } /** Blaetter, auf denen die Zeichnung steht - erkennbar am Verweisabsatz. */ function zeichenblaetter(bytes: Uint8Array): readonly Seite[] { return seiten(bytes).filter((s) => s.text.includes('Balkendiagramm')); } describe('L10 - Dokumenttitel im Anzeigeprogramm', () => { it('setzt DisplayDocTitle, damit nicht der Dateiname angesagt wird', () => { const roh = struktur(ausdruck(createStandardIntersectionProject('Musterkreuzung', STICHTAG))); expect(roh).toContain('/ViewerPreferences'); expect(roh).toContain('/DisplayDocTitle true'); // Der Titel selbst war schon immer gesetzt; ohne ihn bewirkt der Schalter // nichts. expect(roh).toContain(gedruckt('/Title (Musterkreuzung – Signalzeitenplan)')); }); }); describe('S1 - keine Auszeichnung behaupten, die es nicht gibt', () => { /* * Diese Pruefung behebt nichts; sie haelt eine Zusage fest. "/MarkInfo << * /Marked true >>" liesse sich ueber die Ereignishaken von jsPDF in drei * Zeilen setzen, und das Dokument bestuende danach manche automatische * Pruefung. Ein Strukturbaum entstuende dabei nicht, und Sprachausgaben * schalten bei "/Marked true" von ihren Positionsheuristiken auf den - dann * leeren - Strukturbaum um: Sie lesen danach WENIGER vor als heute. */ it('behauptet weder Marked Content noch einen Strukturbaum', () => { const roh = struktur(ausdruck(createStandardIntersectionProject('Musterkreuzung', STICHTAG))); expect(roh).not.toContain('/MarkInfo'); expect(roh).not.toContain('/Marked'); expect(roh).not.toContain('/StructTreeRoot'); }); }); describe('D-5 - Lesezeichen', () => { it('legt fuer jeden Abschnitt ein Lesezeichen an', () => { const roh = struktur(ausdruck(createStandardIntersectionProject('Musterkreuzung', STICHTAG))); expect(roh).toContain('/Outlines'); expect(roh).toContain('/Type /Outlines'); for (const titel of [ 'Deckblatt', 'Bestandteile der Anordnungsunterlage', 'Signalzeitenplan', 'Signalzeiten je Signalgruppe', 'Zwischenzeitenmatrix', 'Prüfbericht', 'Barrierefreiheit dieser Datei', ]) { expect(roh, titel).toContain(`/Title (${gedruckt(titel)})`); } }); }); describe('S1/D-3 - Verweis auf die gleichwertige Textfassung der Zeichnung', () => { it('benennt die Zeichnung als Zeichnung und die Tabelle als ihre Textfassung', () => { const bytes = ausdruck(createStandardIntersectionProject('Musterkreuzung', STICHTAG)); const text = volltext(bytes); expect(text).toContain(gedruckt('Über diesem Absatz steht eine Zeichnung')); expect(text).toContain( gedruckt('Tabelle „Signalzeiten je Signalgruppe", die auf die Zeichnung folgt'), ); expect(text).toContain(gedruckt('gleichwertige Textfassung der Zeichnung')); // Der Textschluessel zu den Farben steht im Absatz selbst, nicht nur in der // Zeichnung - er muss auch dann tragen, wenn die Zeichnung nicht ankommt. expect(text).toContain(gedruckt('(R = Rot, RG = Rot-Gelb, F = Freigabe, GE = Gelb)')); }); /* * Ein Verweis, den man erst auf der naechsten Seite findet, ist keiner. * Der Platz fuer beide Absaetze wird deshalb abgezogen, BEVOR die Hoehe der * Zeichnung feststeht. Beim ersten Anlauf fehlte in dieser Reserve der * Abstand von 14 pt zwischen Zeichnung und Absatz; bei 13 Signalgruppen * landete die Fussnote daraufhin allein auf einer eigenen Seite. Deshalb * wird hier ueber die Zahl der Signalgruppen geprueft und nicht nur am * Beispielprojekt. */ /* * UND MIT NAMEN, WIE SIE IN EINER ANORDNUNGSUNTERLAGE VORKOMMEN * (Befund B1). * * Die Reserve unter der Zeichnung wurde mit der Fassung OHNE Blattangabe * gemessen, plus pauschal 18 pt "fuer eine Zeile und eine halbe zum * Abrunden". Gedruckt wird bei mehreren Blaettern aber "Blatt 1 von 2 der * Zeichnung, Signalgruppen bis " - und Signalgruppennamen * sind freier Anwendertext. Bei 16 langen Namen wuchs die Blattangabe ueber * die eine reservierte Zeile hinaus, und die Fussnote landete umgeblaettert: * gemessen Zeichnung auf 20 und 22, Fussnote auf 21 und 23. Auf dem * Zeichnungsblatt stand dann nur noch der uneingeschraenkte Satz "die * farbigen Abschnitte tragen ihr Kürzel und ihre Dauer" - die * Richtigstellung fehlte. * * Die Prueffaelle darunter fuehrten ausschliesslich K1 bis Kn; mit so kurzen * Namen tritt der Fall nicht auf. */ for (const [anzahl, teile] of [ [20, 2], [16, 3], ] as const) { it(`${anzahl} Signalgruppen mit langen Namen: Fussnote bleibt beim Blatt`, () => { // Nachgestellt gegen die alte Reserve: Dort steht der Verweis auf // Blatt 35 und 37, die Fussnote auf 36 und 38 (20 Gruppen) bzw. 43/45 // gegen 44/46 (16 Gruppen). const blaetter = zeichenblaetter(ausdruck(mitLangenNamen(anzahl, teile))); expect(blaetter.length).toBeGreaterThanOrEqual(1); for (const blatt of blaetter) { expect(blatt.text, `Blatt ${blatt.nummer}, Verweis`).toContain( gedruckt('gleichwertige Textfassung der Zeichnung'), ); expect(blatt.text, `Blatt ${blatt.nummer}, Fussnote`).toContain( gedruckt('in ihnen steht dann keine Beschriftung'), ); } }); } for (const anzahl of [1, 6, 9, 12, 13, 16, 20, 26]) { it(`${anzahl} Signalgruppen: Verweis und Fussnote stehen auf dem Blatt der Zeichnung`, () => { const blaetter = zeichenblaetter(ausdruck(mitSignalgruppen(anzahl))); expect(blaetter.length).toBeGreaterThanOrEqual(1); for (const blatt of blaetter) { expect(blatt.text, `Blatt ${blatt.nummer}, Verweis`).toContain( gedruckt('gleichwertige Textfassung der Zeichnung'), ); expect(blatt.text, `Blatt ${blatt.nummer}, Fussnote`).toContain( gedruckt('in ihnen steht dann keine Beschriftung'), ); } }); } }); describe('M5/D-6 - Fussnote zu den unbeschrifteten Abschnitten', () => { it('sagt, dass zu schmale Abschnitte unbeschriftet bleiben und wo ihre Dauer steht', () => { const bytes = ausdruck(createStandardIntersectionProject('Musterkreuzung', STICHTAG)); const blaetter = zeichenblaetter(bytes); expect(blaetter[0]!.text).toContain( gedruckt( 'ergeben Abschnitte, die dafür zu schmal sind; in ihnen steht dann keine Beschriftung', ), ); expect(blaetter[0]!.text).toContain( gedruckt( 'führt die Tabelle „Signalzeiten je Signalgruppe" das Signalbild mit seiner Dauer auf', ), ); expect(blaetter[0]!.text).toContain( gedruckt( 'Bei Farbfehlsichtigkeit und im Schwarzweißausdruck ist sie deshalb die verlässliche Quelle', ), ); }); it('nimmt der Zeichnung ihren eigenen Hinweis nicht vorweg', () => { /* * Die Zeichnung benennt seit Fassung 5.7.0 unter ihrer Legende, welche * Signalbilder es diesmal getroffen hat - das kann nur sie, weil nur dort * gemessen wird, was noch hineinpasst. Die Fussnote hier sagt die Regel * und nennt die Tabelle. Beruefe sie sich auf den Hinweis der Zeichnung, * waere sie falsch, sobald die Zeichnung ihn weglaesst (und sie laesst ihn * weg, wenn jeder Abschnitt sein Kuerzel bekommen hat). */ const bytes = ausdruck(createStandardIntersectionProject('Musterkreuzung', STICHTAG)); const blatt = zeichenblaetter(bytes)[0]!; expect(blatt.text).not.toContain(gedruckt('vermerkt die Zeichnung')); expect(blatt.text).not.toContain(gedruckt('unter ihrer Legende')); }); }); describe('M12 - die Zeichnung bleibt mit ihrer Legende auf der Seite', () => { /* * Der Altstand kappte die Hoehe mit Math.min, ohne der Zeichnung davon zu * sagen: drawSignalPlan rechnet seine Zeilen aus dem Layout, nicht aus der * Flaechenhoehe, und PdfSurface beschneidet nicht. Gemessen am Altstand lag * der unterste gezeichnete Rand bei 16 Signalgruppen 11,7 pt UNTERHALB der * Blattkante (-11,7 in PDF-Koordinaten), bei 20 Signalgruppen 123,7 pt. * Die Legende steht ganz unten und fiel als Erstes weg; danach trug in der * Zeichnung nur noch die Farbe. */ for (const anzahl of [6, 12, 13, 16, 20, 26]) { it(`${anzahl} Signalgruppen: nichts wird unter die Blattkante gezeichnet`, () => { const bytes = ausdruck(mitSignalgruppen(anzahl)); const blaetter = zeichenblaetter(bytes); expect(blaetter.length).toBeGreaterThanOrEqual(1); for (const blatt of blaetter) { expect(blatt.unterster, `Blatt ${blatt.nummer}`).toBeGreaterThanOrEqual(UNTERKANTE); } }); it(`${anzahl} Signalgruppen: die Legende ist auf jedem Blatt lesbar`, () => { const bytes = ausdruck(mitSignalgruppen(anzahl)); const blaetter = zeichenblaetter(bytes); // Ohne diese Zeile prueft die Schleife darunter nichts, sobald es kein // Zeichenblatt gibt - und ginge damit auch gegen den Altstand durch. expect(blaetter.length).toBeGreaterThanOrEqual(1); for (const blatt of blaetter) { for (const eintrag of ['R = Rot', 'RG = Rot-Gelb', 'F = Freigabe', 'GE = Gelb']) { expect(blatt.text, `Blatt ${blatt.nummer}, ${eintrag}`).toContain(gedruckt(eintrag)); } } }); } it('teilt erst auf, wenn es sein muss - und dann gleichmaessig', () => { // Bis zu 13 Signalgruppen traegt ein Blatt; darueber wird geteilt, aber // nicht in 14 und 2: Zeilen zweier so verschiedener Blaetter liessen sich // nicht mehr nebeneinanderlegen. expect(zeichenblaetter(ausdruck(mitSignalgruppen(6)))).toHaveLength(1); expect(zeichenblaetter(ausdruck(mitSignalgruppen(13)))).toHaveLength(1); const bytes = ausdruck(mitSignalgruppen(16)); const blaetter = zeichenblaetter(bytes); expect(blaetter).toHaveLength(2); expect(blaetter[0]!.text).toContain( gedruckt('Blatt 1 von 2 der Zeichnung, Signalgruppen K1 bis K8'), ); expect(blaetter[1]!.text).toContain( gedruckt('Blatt 2 von 2 der Zeichnung, Signalgruppen K9 bis K16'), ); // Jedes Blatt traegt seinen eigenen Verweis auf die Textfassung. for (const blatt of blaetter) { expect(blatt.text).toContain(gedruckt('gleichwertige Textfassung der Zeichnung')); } }); it('nennt das Fortsetzungsblatt im Lesezeichenverzeichnis', () => { const roh = struktur(ausdruck(mitSignalgruppen(16))); expect(roh).toContain('/Title (Signalzeitenplan \\(Fortsetzung\\))'); }); }); describe('Erklaerung zur Barrierefreiheit im Dokument', () => { const bytes = ausdruck(createStandardIntersectionProject('Musterkreuzung', STICHTAG)); const text = volltext(bytes); it('nennt die Luecke beim Namen, statt sie zu verschweigen', () => { expect(text).toContain(gedruckt('Barrierefreiheit dieser Datei')); expect(text).toContain(gedruckt('Diese Datei ist nicht getaggt')); expect(text).toContain(gedruckt('erfüllt PDF/UA (DIN ISO 14289-1) nicht')); expect(text).toContain( gedruckt('1.3.1 Info und Beziehungen, 1.3.2 Bedeutungstragende Reihenfolge und 1.1.1'), ); }); it('sagt, was das fuer Bildschirmleseprogramme bedeutet', () => { expect(text).toContain(gedruckt('Überschriften werden nicht als Überschriften angesagt')); expect(text).toContain(gedruckt('ohne Bezug zu Zeilen- und Spaltenköpfen vorgelesen')); expect(text).toContain(gedruckt('Kopf- und Fußzeile jeder Seite stehen im Lesefluss')); }); it('weist die Signalzeitentabelle als Textfassung der Zeichnung aus', () => { expect(text).toContain( gedruckt('Die Signalbilder der Zeichnung stehen vollständig in Textform in der Tabelle'), ); // Und sagt zugleich, was sie NICHT ist - sonst waere der Satz eine Zusage, // die das Dokument nicht halten kann. expect(text).toContain( gedruckt('ein Alternativtext im Sinne von 1.1.1 ist sie mangels Auszeichnung nicht'), ); }); /* * Die Zusage ist auf das eingegrenzt, was die Tabelle wirklich enthaelt. * * Zuvor stand da: "Der Inhalt der Zeichnung steht VOLLSTAENDIG in Textform in * der Tabelle „Signalzeiten je Signalgruppe"". Die Zeichnung traegt ueber der * Zeitachse aber ein Phasenband - Phasennamen, Phasenbeginn, * Uebergangszeiten -, und davon steht in dieser Tabelle nichts. Der Abschnitt * ist ein Lueckenausweis; er darf als einziger Teil der Datei keine Angabe * machen, die einer Nachpruefung nicht standhaelt. */ it('nennt fuer das Phasenband die zweite Tabelle und behauptet nicht, eine reiche', () => { expect(text).toContain(gedruckt('Das Phasenband über der Zeitachse')); expect(text).toContain(gedruckt('Abschnitt „Phasen und Phasenübergänge"')); expect(text).toContain(gedruckt('Diese beiden Tabellen zusammen sind die gleichwertige')); // Die zu weit gefasste Fassung darf nicht wieder auftauchen. expect(text).not.toContain(gedruckt('Der Inhalt der Zeichnung steht vollständig')); }); it('benennt die Luecke, wenn der Phasenabschnitt abgewaehlt wurde', () => { // Ein Verweis auf einen Abschnitt, den diese Ausgabe gar nicht enthaelt, // waere genau die Art Zusage, die der Abschnitt vermeiden soll. const projekt = createStandardIntersectionProject('Musterkreuzung', STICHTAG); const plan = buildSignalPlan(projekt); const ohnePhasen = volltext( buildProjectPdf( projekt, plan, validateProject(projekt, plan, STICHTAG), { ...DEFAULT_PDF_OPTIONS, includePhases: false }, STICHTAG, ), ); expect(ohnePhasen).toContain(gedruckt('wurde für diese Ausgabe abgewählt')); expect(ohnePhasen).not.toContain(gedruckt('Diese beiden Tabellen zusammen')); }); it('sagt der veroeffentlichenden Stelle, was sie zu tun hat', () => { expect(text).toContain(gedruckt('§ 27a VwVfG')); expect(text).toContain(gedruckt('EN 301 549 Kapitel 10')); expect(text).toContain(gedruckt('PAC oder veraPDF')); expect(text).toContain(gedruckt('zugängliche Fassung des Inhalts bereitzustellen')); expect(text).toContain(gedruckt('keine Rechtsberatung')); }); it('behauptet an keiner Stelle Konformitaet', () => { // Erst nachweisen, dass die Erklaerung ueberhaupt da ist: Ein Dokument // ohne sie besteht die Suche nach unzulaessigen Zusagen von selbst. expect(text).toContain(gedruckt('Barrierefreiheit dieser Datei')); for (const zusage of [ 'ist barrierefrei', 'barrierefreie PDF', 'WCAG 2.1 AA erfüllt', 'erfüllt die Anforderungen der EN 301 549', 'PDF/UA-konform', ]) { expect(text, zusage).not.toContain(gedruckt(zusage)); } }); it('steht in der Uebersicht der Bestandteile, damit man sie findet', () => { expect(text).toContain( gedruckt( 'Erklärung zur Barrierefreiheit dieser Datei ja – Abschnitt „Barrierefreiheit dieser Datei"', ), ); }); });