waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Erzeugt `icon.ico` und `icon.png` aus `build-resources/symbol.svg`. |
| 3 | * |
| 4 | * ## Warum es dieses Werkzeug gibt |
| 5 | * |
| 6 | * Das Programmsymbol ist eine gezeichnete Form, keine Schrift. Damit es |
| 7 | * überall gleich aussieht, muss es in Bilder umgerechnet werden — in die zehn |
| 8 | * Größen von {@link GROESSEN} für den ICO-Behälter, weil Windows je nach Ort |
| 9 | * eine andere greift (16 in der Titelleiste, 24 in der Taskleiste, 32 im |
| 10 | * Explorer, 256 in der großen Kachelansicht), und dazu {@link PNG_GROESSE} für |
| 11 | * macOS. Von Hand ist das genau einmal richtig und danach nie wieder. |
| 12 | * |
| 13 | * ## Warum kein Zusatzprogramm |
| 14 | * |
| 15 | * Gerastert wird mit demselben Chromium, mit dem das Projekt ohnehin prüft. |
| 16 | * Eine eigene Abhängigkeit für eine Aufgabe, die alle paar Monate anfällt, |
| 17 | * müsste im Gate mitgeprüft und in der Lizenzübersicht geführt werden; das |
| 18 | * steht in keinem Verhältnis. Die Pixel kommen aus einer Leinwand im Browser, |
| 19 | * die PNG-Kodierung ebenfalls. Zusammengesetzt wird nur der ICO-Behälter — |
| 20 | * das sind ein Kopf, ein Verzeichnis und die Bilddaten. |
| 21 | * |
| 22 | * ## Was in der ICO-Datei steht |
| 23 | * |
| 24 | * Bis 96 Bildpunkte liegen die Bilder als unkomprimiertes BMP darin, ab 128 |
| 25 | * als PNG. Das ist die Aufteilung, die auch Symboleditoren wählen: PNG in |
| 26 | * kleinen Größen versteht zwar jedes heutige Windows, aber BMP versteht auch |
| 27 | * jedes ältere, und bei 16 x 16 macht die Ersparnis nichts aus. |
| 28 | * |
| 29 | * ## Aufruf |
| 30 | * |
| 31 | * npm run symbol |
| 32 | * |
| 33 | * Danach steht in `symbol.json` die Prüfsumme der Quelle. `symbol.test.ts` |
| 34 | * vergleicht sie und schlägt an, wenn jemand die Zeichnung ändert, ohne die |
| 35 | * Bilder neu zu erzeugen. |
| 36 | */ |
| 37 | |
| 38 | import { createHash } from 'node:crypto'; |
| 39 | import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; |
| 40 | import { dirname, join } from 'node:path'; |
| 41 | import { fileURLToPath } from 'node:url'; |
| 42 | |
| 43 | import { chromium } from '@playwright/test'; |
| 44 | |
| 45 | const hier = dirname(fileURLToPath(import.meta.url)); |
| 46 | const app = join(hier, '..'); |
| 47 | const quellordner = join(app, 'build-resources'); |
| 48 | |
| 49 | const QUELLE = join(quellordner, 'symbol.svg'); |
| 50 | const ICO = join(quellordner, 'icon.ico'); |
| 51 | const PNG = join(quellordner, 'icon.png'); |
| 52 | const BUCH = join(quellordner, 'symbol.json'); |
| 53 | /** Ordner, in dem electron-builder die Store-Grafiken sucht. */ |
| 54 | const APPX = join(quellordner, 'appx'); |
| 55 | |
| 56 | /** |
| 57 | * Die Größen in der ICO-Datei. |
| 58 | * |
| 59 | * Windows greift je nach Ort eine andere und rechnet sonst selbst um — was |
| 60 | * bei kleinen Größen sichtbar schlechter aussieht als eine eigens gerechnete. |
| 61 | * 20, 40 und 96 sind die Zwischenstufen, die Windows bei 125-, 150- und |
| 62 | * 200-prozentiger Anzeigegröße verlangt. |
| 63 | */ |
| 64 | const GROESSEN = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256]; |
| 65 | |
| 66 | /** Ab dieser Größe liegt das Bild als PNG in der Datei, darunter als BMP. */ |
| 67 | const AB_PNG = 128; |
| 68 | |
| 69 | /** Die Kantenlänge der eigenständigen PNG-Datei (macOS, Linux). */ |
| 70 | const PNG_GROESSE = 1024; |
| 71 | |
| 72 | /** |
| 73 | * Die Grafiken des Store-Pakets (AppX/MSIX). |
| 74 | * |
| 75 | * ## Warum sie hier entstehen müssen |
| 76 | * |
| 77 | * electron-builder sucht sie ausschließlich unter |
| 78 | * `<buildResources>/appx/` und **schweigt**, wenn es sie nicht findet: Es |
| 79 | * greift dann zu `SampleAppx.*` aus seinem eigenen Werkzeugpaket. Am Paket |
| 80 | * 0.22.0 nachgemessen lagen genau diese Beispieldateien darin, byteweise |
| 81 | * gleich – Store-Eintrag, Startmenü-Kachel und Taskleiste zeigten also ein |
| 82 | * fremdes Platzhalterbild. Der Store hat es am 31.08.2026 unter Richtlinie |
| 83 | * 10.1.1.11 („On Device Tiles“) beanstandet. `icon.ico` und `icon.png` fasst |
| 84 | * das AppX-Ziel nicht an; die eine geht in die exe, die andere nach macOS. |
| 85 | * |
| 86 | * ## Warum diese sechs |
| 87 | * |
| 88 | * Die ersten vier sind die Pflichtgrafiken des Manifests und zugleich genau |
| 89 | * die vier, für die electron-builder ein Beispielbild einsetzt. |
| 90 | * |
| 91 | * `SmallTile.png` und `LargeTile.png` kamen mit 0.26.3 dazu. Sie sind keine |
| 92 | * Pflicht, aber electron-builder trägt sie in das Manifest ein, sobald es |
| 93 | * sie findet (`Square71x71Logo` und `Square310x310Logo` in `defaultTileTag`, |
| 94 | * `AppxTarget.js`) – und ohne sie hat Windows für die kleine und die große |
| 95 | * Kachel nichts von diesem Programm. Die Namen stammen aus electron-builder |
| 96 | * und sind nicht frei wählbar. |
| 97 | * |
| 98 | * Drei Größen sind quadratisch, die breite Kachel ist es nicht: Dort steht |
| 99 | * das Zeichen mittig auf der Grundfarbe der Zeichnung, damit die Kachel |
| 100 | * nicht halb durchsichtig ist. |
| 101 | */ |
| 102 | const KACHELN = [ |
| 103 | { datei: 'StoreLogo.png', breite: 50, hoehe: 50 }, |
| 104 | { datei: 'Square44x44Logo.png', breite: 44, hoehe: 44 }, |
| 105 | { datei: 'Square150x150Logo.png', breite: 150, hoehe: 150 }, |
| 106 | { datei: 'Wide310x150Logo.png', breite: 310, hoehe: 150 }, |
| 107 | { datei: 'SmallTile.png', breite: 71, hoehe: 71 }, |
| 108 | { datei: 'LargeTile.png', breite: 310, hoehe: 310 }, |
| 109 | ]; |
| 110 | |
| 111 | /** Grundfarbe der Zeichnung – dieselbe wie das Feld in `symbol.svg`. */ |
| 112 | const KACHELGRUND = '#17505f'; |
| 113 | |
| 114 | // ── Bilder holen ─────────────────────────────────────────────────────────── |
| 115 | |
| 116 | /** |
| 117 | * Rastert die Zeichnung in allen verlangten Größen. |
| 118 | * |
| 119 | * Gezeichnet wird in eine Leinwand statt über einen Bildschirmabzug: Die |
| 120 | * Leinwand liefert die Bildpunkte unmittelbar, ohne Umweg über Fenstergröße |
| 121 | * und Geräteauflösung, und dieselbe Leinwand kodiert das PNG gleich mit. |
| 122 | */ |
| 123 | async function rastern(svg, groessen) { |
| 124 | const browser = await chromium.launch(); |
| 125 | try { |
| 126 | const seite = await browser.newPage(); |
| 127 | return await seite.evaluate( |
| 128 | /* eslint-disable no-undef -- Dieser Rumpf läuft nicht hier, sondern im |
| 129 | Browser: Playwright schickt ihn als Zeichenkette hinüber. `Image` und |
| 130 | `document` gibt es dort, in Node nicht. Der Linter sieht die Datei als |
| 131 | Node-Umgebung und kann den Wechsel nicht erkennen. */ |
| 132 | async ({ svg: zeichnung, groessen: liste }) => { |
| 133 | const adresse = URL.createObjectURL(new Blob([zeichnung], { type: 'image/svg+xml' })); |
| 134 | const bild = new Image(); |
| 135 | bild.src = adresse; |
| 136 | await bild.decode(); |
| 137 | |
| 138 | const ergebnis = {}; |
| 139 | for (const n of liste) { |
| 140 | const leinwand = document.createElement('canvas'); |
| 141 | leinwand.width = n; |
| 142 | leinwand.height = n; |
| 143 | const stift = leinwand.getContext('2d'); |
| 144 | stift.clearRect(0, 0, n, n); |
| 145 | stift.drawImage(bild, 0, 0, n, n); |
| 146 | ergebnis[n] = { |
| 147 | roh: Array.from(stift.getImageData(0, 0, n, n).data), |
| 148 | png: leinwand.toDataURL('image/png').split(',')[1], |
| 149 | }; |
| 150 | } |
| 151 | URL.revokeObjectURL(adresse); |
| 152 | return ergebnis; |
| 153 | }, |
| 154 | /* eslint-enable no-undef */ |
| 155 | { svg, groessen }, |
| 156 | ); |
| 157 | } finally { |
| 158 | await browser.close(); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Rastert die Zeichnung auf Leinwände beliebigen Seitenverhältnisses. |
| 164 | * |
| 165 | * Für die breite Store-Kachel: Das Zeichen bleibt quadratisch und sitzt |
| 166 | * mittig, der Rest bekommt die Grundfarbe der Zeichnung. Ein durchsichtiger |
| 167 | * Rand sähe je nach Windows-Design anders aus als das Feld daneben. |
| 168 | */ |
| 169 | async function kachelnRastern(svg, kacheln, grund) { |
| 170 | const browser = await chromium.launch(); |
| 171 | try { |
| 172 | const seite = await browser.newPage(); |
| 173 | return await seite.evaluate( |
| 174 | /* eslint-disable no-undef -- läuft im Browser, siehe `rastern`. */ |
| 175 | async ({ svg: zeichnung, kacheln: liste, grund: farbe }) => { |
| 176 | const adresse = URL.createObjectURL(new Blob([zeichnung], { type: 'image/svg+xml' })); |
| 177 | const bild = new Image(); |
| 178 | bild.src = adresse; |
| 179 | await bild.decode(); |
| 180 | |
| 181 | const ergebnis = {}; |
| 182 | for (const { datei, breite, hoehe } of liste) { |
| 183 | const leinwand = document.createElement('canvas'); |
| 184 | leinwand.width = breite; |
| 185 | leinwand.height = hoehe; |
| 186 | const stift = leinwand.getContext('2d'); |
| 187 | const kante = Math.min(breite, hoehe); |
| 188 | if (breite !== hoehe) { |
| 189 | stift.fillStyle = farbe; |
| 190 | stift.fillRect(0, 0, breite, hoehe); |
| 191 | } else { |
| 192 | stift.clearRect(0, 0, breite, hoehe); |
| 193 | } |
| 194 | stift.drawImage(bild, (breite - kante) / 2, (hoehe - kante) / 2, kante, kante); |
| 195 | ergebnis[datei] = leinwand.toDataURL('image/png').split(',')[1]; |
| 196 | } |
| 197 | URL.revokeObjectURL(adresse); |
| 198 | return ergebnis; |
| 199 | }, |
| 200 | /* eslint-enable no-undef */ |
| 201 | { svg, kacheln, grund }, |
| 202 | ); |
| 203 | } finally { |
| 204 | await browser.close(); |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // ── ICO zusammensetzen ───────────────────────────────────────────────────── |
| 209 | |
| 210 | /** |
| 211 | * Ein Bild als unkomprimiertes BMP, wie es in einer ICO-Datei steht. |
| 212 | * |
| 213 | * Zwei Eigenheiten des Formats: Die angegebene Höhe ist die doppelte, weil |
| 214 | * hinter den Bildpunkten noch eine Maske steht; und die Zeilen laufen von |
| 215 | * unten nach oben. Die Maske bleibt leer — die Durchsichtigkeit steht schon |
| 216 | * im Alphakanal jedes Bildpunkts. |
| 217 | */ |
| 218 | function alsBmp(n, roh) { |
| 219 | const kopf = Buffer.alloc(40); |
| 220 | kopf.writeUInt32LE(40, 0); // Größe dieses Kopfes |
| 221 | kopf.writeInt32LE(n, 4); // Breite |
| 222 | kopf.writeInt32LE(n * 2, 8); // Höhe, doppelt gezählt |
| 223 | kopf.writeUInt16LE(1, 12); // Ebenen |
| 224 | kopf.writeUInt16LE(32, 14); // Bit je Bildpunkt |
| 225 | kopf.writeUInt32LE(0, 16); // keine Kompression |
| 226 | |
| 227 | const punkte = Buffer.alloc(n * n * 4); |
| 228 | for (let zielzeile = 0; zielzeile < n; zielzeile += 1) { |
| 229 | const quellzeile = n - 1 - zielzeile; |
| 230 | for (let x = 0; x < n; x += 1) { |
| 231 | const q = (quellzeile * n + x) * 4; |
| 232 | const z = (zielzeile * n + x) * 4; |
| 233 | punkte[z] = roh[q + 2]; // Blau |
| 234 | punkte[z + 1] = roh[q + 1]; // Grün |
| 235 | punkte[z + 2] = roh[q]; // Rot |
| 236 | punkte[z + 3] = roh[q + 3]; // Deckung |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // Die Maske ist ein Bit je Bildpunkt, jede Zeile auf vier Byte aufgefüllt. |
| 241 | const maske = Buffer.alloc(Math.ceil(n / 32) * 4 * n); |
| 242 | |
| 243 | kopf.writeUInt32LE(punkte.length + maske.length, 20); |
| 244 | return Buffer.concat([kopf, punkte, maske]); |
| 245 | } |
| 246 | |
| 247 | /** Setzt die einzelnen Bilder zu einer ICO-Datei zusammen. */ |
| 248 | function alsIco(bilder) { |
| 249 | const kopf = Buffer.alloc(6); |
| 250 | kopf.writeUInt16LE(0, 0); // Vorbehalten |
| 251 | kopf.writeUInt16LE(1, 2); // 1 = Symbol |
| 252 | kopf.writeUInt16LE(bilder.length, 4); |
| 253 | |
| 254 | const verzeichnis = []; |
| 255 | let versatz = 6 + 16 * bilder.length; |
| 256 | |
| 257 | for (const { groesse, daten } of bilder) { |
| 258 | const eintrag = Buffer.alloc(16); |
| 259 | // 256 wird als 0 geschrieben - ein Byte fasst die Zahl nicht. |
| 260 | eintrag.writeUInt8(groesse === 256 ? 0 : groesse, 0); |
| 261 | eintrag.writeUInt8(groesse === 256 ? 0 : groesse, 1); |
| 262 | eintrag.writeUInt8(0, 2); // keine Farbtafel |
| 263 | eintrag.writeUInt8(0, 3); // vorbehalten |
| 264 | eintrag.writeUInt16LE(1, 4); // Ebenen |
| 265 | eintrag.writeUInt16LE(32, 6); // Bit je Bildpunkt |
| 266 | eintrag.writeUInt32LE(daten.length, 8); |
| 267 | eintrag.writeUInt32LE(versatz, 12); |
| 268 | verzeichnis.push(eintrag); |
| 269 | versatz += daten.length; |
| 270 | } |
| 271 | |
| 272 | return Buffer.concat([kopf, ...verzeichnis, ...bilder.map((b) => b.daten)]); |
| 273 | } |
| 274 | |
| 275 | // ── Ablauf ───────────────────────────────────────────────────────────────── |
| 276 | |
| 277 | const svg = readFileSync(QUELLE, 'utf8'); |
| 278 | const alle = [...GROESSEN, PNG_GROESSE]; |
| 279 | const bilder = await rastern(svg, alle); |
| 280 | |
| 281 | const eintraege = GROESSEN.map((groesse) => ({ |
| 282 | groesse, |
| 283 | daten: |
| 284 | groesse >= AB_PNG |
| 285 | ? Buffer.from(bilder[groesse].png, 'base64') |
| 286 | : alsBmp(groesse, bilder[groesse].roh), |
| 287 | })); |
| 288 | |
| 289 | writeFileSync(ICO, alsIco(eintraege)); |
| 290 | writeFileSync(PNG, Buffer.from(bilder[PNG_GROESSE].png, 'base64')); |
| 291 | |
| 292 | /* Die vier Pflichtgrafiken des Store-Pakets. Ohne sie greift |
| 293 | electron-builder still zu seinen Beispielbildern – siehe `KACHELN`. */ |
| 294 | mkdirSync(APPX, { recursive: true }); |
| 295 | const kachelbilder = await kachelnRastern(svg, KACHELN, KACHELGRUND); |
| 296 | for (const { datei } of KACHELN) { |
| 297 | writeFileSync(join(APPX, datei), Buffer.from(kachelbilder[datei], 'base64')); |
| 298 | } |
| 299 | |
| 300 | const pruefsumme = createHash('sha256').update(svg, 'utf8').digest('hex'); |
| 301 | writeFileSync( |
| 302 | BUCH, |
| 303 | `${JSON.stringify( |
| 304 | { |
| 305 | hinweis: |
| 306 | 'Erzeugt von tools/symbol-bauen.mjs. Nicht von Hand ändern — ' + |
| 307 | 'die Prüfsumme belegt, dass icon.ico und icon.png zu symbol.svg passen.', |
| 308 | quelle: 'symbol.svg', |
| 309 | pruefsumme, |
| 310 | groessen: GROESSEN, |
| 311 | pngGroesse: PNG_GROESSE, |
| 312 | kacheln: KACHELN, |
| 313 | }, |
| 314 | null, |
| 315 | 2, |
| 316 | )}\n`, |
| 317 | ); |
| 318 | |
| 319 | const summe = eintraege.reduce((s, e) => s + e.daten.length, 0); |
| 320 | console.log( |
| 321 | `icon.ico: ${String(GROESSEN.length)} Größen (${GROESSEN.join(', ')}), ${String(Math.round(summe / 1024))} KiB`, |
| 322 | ); |
| 323 | console.log(`icon.png: ${String(PNG_GROESSE)} x ${String(PNG_GROESSE)}`); |
| 324 | console.log( |
| 325 | `appx/: ${KACHELN.map((k) => `${k.datei} ${String(k.breite)}x${String(k.hoehe)}`).join(', ')}`, |
| 326 | ); |
| 327 | console.log(`symbol.json: Prüfsumme ${pruefsumme.slice(0, 12)}…`); |