waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tools symbol-bauen.mjs

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