waffensachkunde

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

/ app tools symbol-bauen.mjs

12,6 KB Rohdatei
app/tools/symbol-bauen.mjs — 327 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 — 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)}…`);