lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ src render pdfSurface.ts

10,3 KB Rohdatei
src/render/pdfSurface.ts — 262 Zeilen
1 import { GState, type jsPDF } from 'jspdf';
2 import { farbanteil } from './farbe';
3 import type { Surface, TextStyle } from './surface';
4 import { aufDruckzeichen } from '@/domain/druckzeichen';
5
6 /**
7 * Zeichenflaeche auf einer PDF-Seite.
8 *
9 * Damit gibt der Ausdruck denselben Signalzeitenplan aus wie der Bildschirm -
10 * es gibt nur eine Zeichenbeschreibung (signalPlanDrawing.ts).
11 *
12 * Der Altbestand legte den Plan als Rastergrafik ueber html2canvas ins PDF.
13 * Das Ergebnis war unscharf, von der Bildschirmgroesse abhaengig und im
14 * Ausdruck bei kleinen Zeitabschnitten nicht mehr lesbar. Hier entstehen echte
15 * Vektorgrafiken.
16 */
17 /**
18 * WARUM DIESE FLAECHE HALBDURCHSICHTIGE FARBEN GESONDERT BEHANDELT
19 *
20 * BEFUND AUS DEM BETRIEB (11.09.2026, mit Ausdruck): Das Lageplanblatt druckte
21 * den Arbeitsbereich als SCHWARZEN KLOTZ und die Haltlinien als schwarze
22 * Balken. Beide sind auf dem Bildschirm blass: `#c2410c22` und `#b3060455`,
23 * also achtstellige Farben mit Alphakanal. Der Bildschirm kann das, jsPDF
24 * nicht: `setDrawColor` versteht die achte Stelle nicht und faellt auf Schwarz
25 * zurueck. Aus einer 13 Prozent deckenden Flaeche wurde die deckendste, die es
26 * gibt, und sie verdeckte gerade die Fahrlinien, um die es auf dem Blatt geht.
27 *
28 * WARUM DIE ERSTE BEHEBUNG ERSETZT IST. Fassung 5.39.1 verrechnete die Farbe
29 * ueber Weiss und uebergab das Ergebnis als deckende Farbe - richtig, solange
30 * unter der Zeichnung nur Papier liegt. Seit dem Luftbildblatt liegt dort ein
31 * Bild, und eine ueber Weiss verrechnete Flaeche deckt es vollstaendig ab: Aus
32 * 13 Prozent Deckung wurde diesmal ein blasser statt eines schwarzen Klotzes,
33 * und verdeckt war wieder genau das, was das Blatt zeigen soll. Der Fehler war
34 * also nicht behoben, sondern umgefaerbt.
35 *
36 * jsPDF KANN Transparenz - ueber den Grafikzustand (`GState`), der im Dokument
37 * als `/ExtGState` mit `/ca` und `/CA` landet. Das war beim ersten Mal nicht
38 * nachgesehen worden. Damit gilt jetzt EIN Mechanismus fuer beide Faelle: Die
39 * Grundfarbe geht an jsPDF, die Deckung an den Grafikzustand. Ueber weissem
40 * Papier kommt dasselbe heraus wie mit der Verrechnung, ueber dem Bild das
41 * Richtige.
42 */
43 export class PdfSurface implements Surface {
44 readonly width: number;
45 readonly height: number;
46
47 constructor(
48 private readonly doc: jsPDF,
49 private readonly originX: number,
50 private readonly originY: number,
51 width: number,
52 height: number,
53 ) {
54 this.width = width;
55 this.height = height;
56 }
57
58 rect(x: number, y: number, width: number, height: number, fill: string): void {
59 if (width <= 0 || height <= 0) return;
60 this.mitDeckung(fill, (grund) => {
61 this.doc.setFillColor(grund);
62 this.doc.rect(this.originX + x, this.originY + y, width, height, 'F');
63 });
64 }
65
66 polygon(points: readonly { readonly x: number; readonly y: number }[], fill: string): void {
67 if (points.length < 3) return;
68 const erster = points[0];
69 if (erster === undefined) return;
70 /*
71 * jsPDF kennt keinen Vieleckbefehl, sondern nur einen Streckenzug aus
72 * RELATIVEN Abschnitten. Der letzte Abschnitt zurueck zum Anfang wird
73 * deshalb selbst gebildet; `closed` allein schliesst die Kontur zwar, aber
74 * der Streckenzug muss sie erst bis dorthin fuehren.
75 */
76 const abschnitte: [number, number][] = [];
77 for (let i = 1; i < points.length; i += 1) {
78 const vorher = points[i - 1];
79 const jetzt = points[i];
80 if (vorher === undefined || jetzt === undefined) return;
81 abschnitte.push([jetzt.x - vorher.x, jetzt.y - vorher.y]);
82 }
83 const letzter = points[points.length - 1];
84 if (letzter === undefined) return;
85 abschnitte.push([erster.x - letzter.x, erster.y - letzter.y]);
86 this.mitDeckung(fill, (grund) => {
87 this.doc.setFillColor(grund);
88 this.doc.lines(
89 abschnitte,
90 this.originX + erster.x,
91 this.originY + erster.y,
92 [1, 1],
93 'F',
94 true,
95 );
96 });
97 }
98
99 strokeRect(
100 x: number,
101 y: number,
102 width: number,
103 height: number,
104 color: string,
105 lineWidth = 0.5,
106 ): void {
107 if (width <= 0 || height <= 0) return;
108 this.mitDeckung(color, (grund) => {
109 this.doc.setDrawColor(grund);
110 this.doc.setLineWidth(lineWidth * 0.75);
111 this.doc.rect(this.originX + x, this.originY + y, width, height, 'S');
112 });
113 }
114
115 line(x1: number, y1: number, x2: number, y2: number, color: string, lineWidth = 0.5): void {
116 this.mitDeckung(color, (grund) => {
117 this.doc.setDrawColor(grund);
118 this.doc.setLineWidth(lineWidth * 0.75);
119 this.doc.setLineDashPattern([], 0);
120 this.doc.line(this.originX + x1, this.originY + y1, this.originX + x2, this.originY + y2);
121 });
122 }
123
124 circle(
125 x: number,
126 y: number,
127 radius: number,
128 fill: string,
129 stroke: string,
130 lineWidth = 0.5,
131 ): void {
132 if (radius <= 0) return;
133 /*
134 * jsPDF kennt `circle` mit den Stilarten F, S und FD. Fuellung und Umriss
135 * gehen deshalb in EINEN Aufruf - zwei Aufrufe zeichneten den Rand ueber
136 * eine eigene Ellipse, und bei kleinen Radien liefe er sichtbar daneben.
137 *
138 * Die Deckung wird fuer beide Farben einmal gesetzt; haetten sie
139 * verschiedene, gaelte die des Umrisses. Das kommt hier nicht vor - der
140 * Signalgeber ist weiss gefuellt und farbig umrandet - und waere es ein
141 * Fall, gehoerte er in zwei Aufrufe und nicht in eine stille Annahme.
142 */
143 const stil = fill !== '' && stroke !== '' ? 'FD' : fill !== '' ? 'F' : 'S';
144 this.mitDeckung(stroke !== '' ? stroke : fill, (grund) => {
145 if (fill !== '') this.doc.setFillColor(farbanteil(fill).grund);
146 if (stroke !== '') this.doc.setDrawColor(grund);
147 this.doc.setLineWidth(lineWidth * 0.75);
148 this.doc.circle(this.originX + x, this.originY + y, radius, stil);
149 });
150 }
151
152 dashedLine(x1: number, y1: number, x2: number, y2: number, color: string, lineWidth = 0.5): void {
153 this.mitDeckung(color, (grund) => {
154 this.doc.setDrawColor(grund);
155 this.doc.setLineWidth(lineWidth * 0.75);
156 this.doc.setLineDashPattern([2, 2], 0);
157 this.doc.line(this.originX + x1, this.originY + y1, this.originX + x2, this.originY + y2);
158 this.doc.setLineDashPattern([], 0);
159 });
160 }
161
162 text(value: string, x: number, y: number, style: TextStyle): void {
163 const text = einzeilig(toWinAnsi(value));
164 if (text === '') return;
165 // jsPDF setzt den Ursprung auf die Grundlinie; Canvas kennt zusaetzlich
166 // "oben" und "mitte". Die Umrechnung haelt beide Ausgaben deckungsgleich.
167 const ascent = style.size * 0.72;
168 const dy =
169 style.baseline === 'mitte'
170 ? ascent / 2 - style.size * 0.06
171 : style.baseline === 'unten'
172 ? 0
173 : ascent;
174
175 this.mitDeckung(style.color ?? '#000000', (grund) => {
176 this.doc.setFont('helvetica', style.bold === true ? 'bold' : 'normal');
177 this.doc.setFontSize(style.size);
178 this.doc.setTextColor(grund);
179 this.doc.text(text, this.originX + x, this.originY + y + dy, {
180 align: style.align === 'mitte' ? 'center' : style.align === 'rechts' ? 'right' : 'left',
181 baseline: 'alphabetic',
182 });
183 });
184 }
185
186 measureText(value: string, style: TextStyle): number {
187 this.doc.setFont('helvetica', style.bold === true ? 'bold' : 'normal');
188 this.doc.setFontSize(style.size);
189 // Gemessen wird dieselbe Zeichenfolge, die `text` setzt (siehe dort):
190 // getTextWidth summiert die Zeichenbreiten EINZEILIG, waehrend doc.text
191 // umbrach - fitText kuerzte damit gegen eine Breite, die es nie gab.
192 return this.doc.getTextWidth(einzeilig(toWinAnsi(value)));
193 }
194
195 /**
196 * Zeichnet mit der Deckung, die in der Farbe steht.
197 *
198 * DER GRAFIKZUSTAND WIRD IMMER ZURUECKGESETZT, auch wenn das Zeichnen
199 * fehlschlaegt: Ein stehengebliebener Zustand faerbte den GANZEN Rest des
200 * Dokuments halbdurchsichtig, und der Ausdruck saehe aus wie ein Entwurf.
201 * Das ist die gefaehrliche Richtung dieses Umbaus, deshalb `finally`.
202 *
203 * Bei voller Deckung wird gar kein Zustand gesetzt. Das haelt jene Seiten
204 * frei von `/ExtGState`, die keine halbdurchsichtige Farbe fuehren - und das
205 * sind alle ausser den beiden Lageplanblaettern.
206 */
207 private mitDeckung(farbe: string, zeichne: (grund: string) => void): void {
208 const { grund, deckung } = farbanteil(farbe);
209 if (deckung >= 1) {
210 zeichne(grund);
211 return;
212 }
213 this.doc.setGState(new GState({ opacity: deckung, 'stroke-opacity': deckung }));
214 try {
215 zeichne(grund);
216 } finally {
217 this.doc.setGState(new GState({ opacity: 1, 'stroke-opacity': 1 }));
218 }
219 }
220 }
221
222 /*
223 * Steuerzeichen als Leerzeichen - diese Flaeche setzt EINZEILIGE
224 * Beschriftungen.
225 *
226 * Fassung 5.10.0: jsPDF zerlegt einen String mit \r oder \n in mehrere Zeilen
227 * und setzt sie untereinander, `ctx.fillText` der Bildschirmflaeche setzt genau
228 * eine. Ein Signalgruppen- oder Phasenname mit \n - aus einer Projektdatei
229 * erreichbar, weil `str` in schema.ts Zeichenketten ungefiltert durchreicht -
230 * stand damit am Bildschirm einzeilig und im Ausdruck zweizeilig, die zweite
231 * Zeile in der Beschriftungsspalte der naechsten Signalgruppe: `planHeight`
232 * (signalPlanDrawing.ts) rechnet je Signalgruppe genau eine Zeile, und diese
233 * Flaeche beschneidet nicht.
234 *
235 * Ersetzt und nicht abgeschnitten: Nur bis zum ersten Umbruch zu setzen
236 * verloere den kennzeichnenden Teil des Namens stillschweigend.
237 *
238 * `toWinAnsi` behaelt den Umbruch weiterhin (siehe dort). Die mehrzeiligen
239 * Drucktexte - Bemerkung auf dem Deckblatt, Zuordnungszeilen unter der
240 * Zwischenzeitenmatrix - gehen unmittelbar ueber `doc.text` und nicht ueber
241 * diese Flaeche; sie brauchen ihn.
242 */
243 // eslint-disable-next-line no-control-regex -- die Steuerzeichen sind hier der Pruefgegenstand; siehe darueber
244 const STEUERZEICHEN = /[\x00-\x1f]/g;
245
246 function einzeilig(value: string): string {
247 return value.replace(STEUERZEICHEN, ' ');
248 }
249
250 /**
251 * Bringt einen Text in den Zeichenvorrat der PDF-Standardschriften (WinAnsi).
252 *
253 * DIE TAFEL SELBST STEHT IN `domain/druckzeichen.ts` und nicht mehr hier: Der
254 * Pruefbericht muss denselben Vorrat kennen, um zu melden, dass ein Zeichen
255 * verschwindet, und er wird in `domain/` gebildet. Zwei Listen liefen
256 * auseinander. Der Name `toWinAnsi` bleibt, weil er an dreissig Stellen des
257 * Ausdrucks steht und dort das Richtige sagt: Was hier hindurchgeht, ist
258 * WinAnsi.
259 */
260 export function toWinAnsi(value: string): string {
261 return aufDruckzeichen(value);
262 }