import { GState, type jsPDF } from 'jspdf'; import { farbanteil } from './farbe'; import type { Surface, TextStyle } from './surface'; import { aufDruckzeichen } from '@/domain/druckzeichen'; /** * Zeichenflaeche auf einer PDF-Seite. * * Damit gibt der Ausdruck denselben Signalzeitenplan aus wie der Bildschirm - * es gibt nur eine Zeichenbeschreibung (signalPlanDrawing.ts). * * Der Altbestand legte den Plan als Rastergrafik ueber html2canvas ins PDF. * Das Ergebnis war unscharf, von der Bildschirmgroesse abhaengig und im * Ausdruck bei kleinen Zeitabschnitten nicht mehr lesbar. Hier entstehen echte * Vektorgrafiken. */ /** * WARUM DIESE FLAECHE HALBDURCHSICHTIGE FARBEN GESONDERT BEHANDELT * * BEFUND AUS DEM BETRIEB (11.09.2026, mit Ausdruck): Das Lageplanblatt druckte * den Arbeitsbereich als SCHWARZEN KLOTZ und die Haltlinien als schwarze * Balken. Beide sind auf dem Bildschirm blass: `#c2410c22` und `#b3060455`, * also achtstellige Farben mit Alphakanal. Der Bildschirm kann das, jsPDF * nicht: `setDrawColor` versteht die achte Stelle nicht und faellt auf Schwarz * zurueck. Aus einer 13 Prozent deckenden Flaeche wurde die deckendste, die es * gibt, und sie verdeckte gerade die Fahrlinien, um die es auf dem Blatt geht. * * WARUM DIE ERSTE BEHEBUNG ERSETZT IST. Fassung 5.39.1 verrechnete die Farbe * ueber Weiss und uebergab das Ergebnis als deckende Farbe - richtig, solange * unter der Zeichnung nur Papier liegt. Seit dem Luftbildblatt liegt dort ein * Bild, und eine ueber Weiss verrechnete Flaeche deckt es vollstaendig ab: Aus * 13 Prozent Deckung wurde diesmal ein blasser statt eines schwarzen Klotzes, * und verdeckt war wieder genau das, was das Blatt zeigen soll. Der Fehler war * also nicht behoben, sondern umgefaerbt. * * jsPDF KANN Transparenz - ueber den Grafikzustand (`GState`), der im Dokument * als `/ExtGState` mit `/ca` und `/CA` landet. Das war beim ersten Mal nicht * nachgesehen worden. Damit gilt jetzt EIN Mechanismus fuer beide Faelle: Die * Grundfarbe geht an jsPDF, die Deckung an den Grafikzustand. Ueber weissem * Papier kommt dasselbe heraus wie mit der Verrechnung, ueber dem Bild das * Richtige. */ export class PdfSurface implements Surface { readonly width: number; readonly height: number; constructor( private readonly doc: jsPDF, private readonly originX: number, private readonly originY: number, width: number, height: number, ) { this.width = width; this.height = height; } rect(x: number, y: number, width: number, height: number, fill: string): void { if (width <= 0 || height <= 0) return; this.mitDeckung(fill, (grund) => { this.doc.setFillColor(grund); this.doc.rect(this.originX + x, this.originY + y, width, height, 'F'); }); } polygon(points: readonly { readonly x: number; readonly y: number }[], fill: string): void { if (points.length < 3) return; const erster = points[0]; if (erster === undefined) return; /* * jsPDF kennt keinen Vieleckbefehl, sondern nur einen Streckenzug aus * RELATIVEN Abschnitten. Der letzte Abschnitt zurueck zum Anfang wird * deshalb selbst gebildet; `closed` allein schliesst die Kontur zwar, aber * der Streckenzug muss sie erst bis dorthin fuehren. */ const abschnitte: [number, number][] = []; for (let i = 1; i < points.length; i += 1) { const vorher = points[i - 1]; const jetzt = points[i]; if (vorher === undefined || jetzt === undefined) return; abschnitte.push([jetzt.x - vorher.x, jetzt.y - vorher.y]); } const letzter = points[points.length - 1]; if (letzter === undefined) return; abschnitte.push([erster.x - letzter.x, erster.y - letzter.y]); this.mitDeckung(fill, (grund) => { this.doc.setFillColor(grund); this.doc.lines( abschnitte, this.originX + erster.x, this.originY + erster.y, [1, 1], 'F', true, ); }); } strokeRect( x: number, y: number, width: number, height: number, color: string, lineWidth = 0.5, ): void { if (width <= 0 || height <= 0) return; this.mitDeckung(color, (grund) => { this.doc.setDrawColor(grund); this.doc.setLineWidth(lineWidth * 0.75); this.doc.rect(this.originX + x, this.originY + y, width, height, 'S'); }); } line(x1: number, y1: number, x2: number, y2: number, color: string, lineWidth = 0.5): void { this.mitDeckung(color, (grund) => { this.doc.setDrawColor(grund); this.doc.setLineWidth(lineWidth * 0.75); this.doc.setLineDashPattern([], 0); this.doc.line(this.originX + x1, this.originY + y1, this.originX + x2, this.originY + y2); }); } circle( x: number, y: number, radius: number, fill: string, stroke: string, lineWidth = 0.5, ): void { if (radius <= 0) return; /* * jsPDF kennt `circle` mit den Stilarten F, S und FD. Fuellung und Umriss * gehen deshalb in EINEN Aufruf - zwei Aufrufe zeichneten den Rand ueber * eine eigene Ellipse, und bei kleinen Radien liefe er sichtbar daneben. * * Die Deckung wird fuer beide Farben einmal gesetzt; haetten sie * verschiedene, gaelte die des Umrisses. Das kommt hier nicht vor - der * Signalgeber ist weiss gefuellt und farbig umrandet - und waere es ein * Fall, gehoerte er in zwei Aufrufe und nicht in eine stille Annahme. */ const stil = fill !== '' && stroke !== '' ? 'FD' : fill !== '' ? 'F' : 'S'; this.mitDeckung(stroke !== '' ? stroke : fill, (grund) => { if (fill !== '') this.doc.setFillColor(farbanteil(fill).grund); if (stroke !== '') this.doc.setDrawColor(grund); this.doc.setLineWidth(lineWidth * 0.75); this.doc.circle(this.originX + x, this.originY + y, radius, stil); }); } dashedLine(x1: number, y1: number, x2: number, y2: number, color: string, lineWidth = 0.5): void { this.mitDeckung(color, (grund) => { this.doc.setDrawColor(grund); this.doc.setLineWidth(lineWidth * 0.75); this.doc.setLineDashPattern([2, 2], 0); this.doc.line(this.originX + x1, this.originY + y1, this.originX + x2, this.originY + y2); this.doc.setLineDashPattern([], 0); }); } text(value: string, x: number, y: number, style: TextStyle): void { const text = einzeilig(toWinAnsi(value)); if (text === '') return; // jsPDF setzt den Ursprung auf die Grundlinie; Canvas kennt zusaetzlich // "oben" und "mitte". Die Umrechnung haelt beide Ausgaben deckungsgleich. const ascent = style.size * 0.72; const dy = style.baseline === 'mitte' ? ascent / 2 - style.size * 0.06 : style.baseline === 'unten' ? 0 : ascent; this.mitDeckung(style.color ?? '#000000', (grund) => { this.doc.setFont('helvetica', style.bold === true ? 'bold' : 'normal'); this.doc.setFontSize(style.size); this.doc.setTextColor(grund); this.doc.text(text, this.originX + x, this.originY + y + dy, { align: style.align === 'mitte' ? 'center' : style.align === 'rechts' ? 'right' : 'left', baseline: 'alphabetic', }); }); } measureText(value: string, style: TextStyle): number { this.doc.setFont('helvetica', style.bold === true ? 'bold' : 'normal'); this.doc.setFontSize(style.size); // Gemessen wird dieselbe Zeichenfolge, die `text` setzt (siehe dort): // getTextWidth summiert die Zeichenbreiten EINZEILIG, waehrend doc.text // umbrach - fitText kuerzte damit gegen eine Breite, die es nie gab. return this.doc.getTextWidth(einzeilig(toWinAnsi(value))); } /** * Zeichnet mit der Deckung, die in der Farbe steht. * * DER GRAFIKZUSTAND WIRD IMMER ZURUECKGESETZT, auch wenn das Zeichnen * fehlschlaegt: Ein stehengebliebener Zustand faerbte den GANZEN Rest des * Dokuments halbdurchsichtig, und der Ausdruck saehe aus wie ein Entwurf. * Das ist die gefaehrliche Richtung dieses Umbaus, deshalb `finally`. * * Bei voller Deckung wird gar kein Zustand gesetzt. Das haelt jene Seiten * frei von `/ExtGState`, die keine halbdurchsichtige Farbe fuehren - und das * sind alle ausser den beiden Lageplanblaettern. */ private mitDeckung(farbe: string, zeichne: (grund: string) => void): void { const { grund, deckung } = farbanteil(farbe); if (deckung >= 1) { zeichne(grund); return; } this.doc.setGState(new GState({ opacity: deckung, 'stroke-opacity': deckung })); try { zeichne(grund); } finally { this.doc.setGState(new GState({ opacity: 1, 'stroke-opacity': 1 })); } } } /* * Steuerzeichen als Leerzeichen - diese Flaeche setzt EINZEILIGE * Beschriftungen. * * Fassung 5.10.0: jsPDF zerlegt einen String mit \r oder \n in mehrere Zeilen * und setzt sie untereinander, `ctx.fillText` der Bildschirmflaeche setzt genau * eine. Ein Signalgruppen- oder Phasenname mit \n - aus einer Projektdatei * erreichbar, weil `str` in schema.ts Zeichenketten ungefiltert durchreicht - * stand damit am Bildschirm einzeilig und im Ausdruck zweizeilig, die zweite * Zeile in der Beschriftungsspalte der naechsten Signalgruppe: `planHeight` * (signalPlanDrawing.ts) rechnet je Signalgruppe genau eine Zeile, und diese * Flaeche beschneidet nicht. * * Ersetzt und nicht abgeschnitten: Nur bis zum ersten Umbruch zu setzen * verloere den kennzeichnenden Teil des Namens stillschweigend. * * `toWinAnsi` behaelt den Umbruch weiterhin (siehe dort). Die mehrzeiligen * Drucktexte - Bemerkung auf dem Deckblatt, Zuordnungszeilen unter der * Zwischenzeitenmatrix - gehen unmittelbar ueber `doc.text` und nicht ueber * diese Flaeche; sie brauchen ihn. */ // eslint-disable-next-line no-control-regex -- die Steuerzeichen sind hier der Pruefgegenstand; siehe darueber const STEUERZEICHEN = /[\x00-\x1f]/g; function einzeilig(value: string): string { return value.replace(STEUERZEICHEN, ' '); } /** * Bringt einen Text in den Zeichenvorrat der PDF-Standardschriften (WinAnsi). * * DIE TAFEL SELBST STEHT IN `domain/druckzeichen.ts` und nicht mehr hier: Der * Pruefbericht muss denselben Vorrat kennen, um zu melden, dass ein Zeichen * verschwindet, und er wird in `domain/` gebildet. Zwei Listen liefen * auseinander. Der Name `toWinAnsi` bleibt, weil er an dreissig Stellen des * Ausdrucks steht und dort das Richtige sagt: Was hier hindurchgeht, ist * WinAnsi. */ export function toWinAnsi(value: string): string { return aufDruckzeichen(value); }