lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 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 | } |