/** * Erzeugt `icon.ico` und `icon.png` aus `build-resources/symbol.svg`. * * ## Warum es dieses Werkzeug gibt * * Das Programmsymbol ist eine gezeichnete Form, keine Schrift. Damit es * überall gleich aussieht, muss es in Bilder umgerechnet werden — und zwar in * ein Dutzend Größen, weil Windows je nach Ort eine andere greift: 16 in der * Titelleiste, 24 in der Taskleiste, 32 im Explorer, 256 in der großen * Kachelansicht. Von Hand ist das genau einmal richtig und danach nie wieder. * * ## Warum kein Zusatzprogramm * * Gerastert wird mit demselben Chromium, mit dem das Projekt ohnehin prüft. * Eine eigene Abhängigkeit für eine Aufgabe, die alle paar Monate anfällt, * müsste im Gate mitgeprüft und in der Lizenzübersicht geführt werden; das * steht in keinem Verhältnis. Die Pixel kommen aus einer Leinwand im Browser, * die PNG-Kodierung ebenfalls. Zusammengesetzt wird nur der ICO-Behälter — * das sind ein Kopf, ein Verzeichnis und die Bilddaten. * * ## Was in der ICO-Datei steht * * Bis 96 Bildpunkte liegen die Bilder als unkomprimiertes BMP darin, ab 128 * als PNG. Das ist die Aufteilung, die auch Symboleditoren wählen: PNG in * kleinen Größen versteht zwar jedes heutige Windows, aber BMP versteht auch * jedes ältere, und bei 16 x 16 macht die Ersparnis nichts aus. * * ## Aufruf * * npm run symbol * * Danach steht in `symbol.json` die Prüfsumme der Quelle. `symbol.test.ts` * vergleicht sie und schlägt an, wenn jemand die Zeichnung ändert, ohne die * Bilder neu zu erzeugen. */ import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from '@playwright/test'; const hier = dirname(fileURLToPath(import.meta.url)); const app = join(hier, '..'); const quellordner = join(app, 'build-resources'); const QUELLE = join(quellordner, 'symbol.svg'); const ICO = join(quellordner, 'icon.ico'); const PNG = join(quellordner, 'icon.png'); const BUCH = join(quellordner, 'symbol.json'); /** Ordner, in dem electron-builder die Store-Grafiken sucht. */ const APPX = join(quellordner, 'appx'); /** * Die Größen in der ICO-Datei. * * Windows greift je nach Ort eine andere und rechnet sonst selbst um — was * bei kleinen Größen sichtbar schlechter aussieht als eine eigens gerechnete. * 20, 40 und 96 sind die Zwischenstufen, die Windows bei 125-, 150- und * 200-prozentiger Anzeigegröße verlangt. */ const GROESSEN = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256]; /** Ab dieser Größe liegt das Bild als PNG in der Datei, darunter als BMP. */ const AB_PNG = 128; /** Die Kantenlänge der eigenständigen PNG-Datei (macOS, Linux). */ const PNG_GROESSE = 1024; /** * Die Grafiken des Store-Pakets (AppX/MSIX). * * ## Warum sie hier entstehen müssen * * electron-builder sucht sie ausschließlich unter * `/appx/` und **schweigt**, wenn es sie nicht findet: Es * greift dann zu `SampleAppx.*` aus seinem eigenen Werkzeugpaket. Am Paket * 0.22.0 nachgemessen lagen genau diese Beispieldateien darin, byteweise * gleich – Store-Eintrag, Startmenü-Kachel und Taskleiste zeigten also ein * fremdes Platzhalterbild. Der Store hat es am 31.08.2026 unter Richtlinie * 10.1.1.11 („On Device Tiles“) beanstandet. `icon.ico` und `icon.png` fasst * das AppX-Ziel nicht an; die eine geht in die exe, die andere nach macOS. * * ## Warum diese sechs * * Die ersten vier sind die Pflichtgrafiken des Manifests und zugleich genau * die vier, für die electron-builder ein Beispielbild einsetzt. * * `SmallTile.png` und `LargeTile.png` kamen mit 0.26.3 dazu. Sie sind keine * Pflicht, aber electron-builder trägt sie in das Manifest ein, sobald es * sie findet (`Square71x71Logo` und `Square310x310Logo` in `defaultTileTag`, * `AppxTarget.js`) – und ohne sie hat Windows für die kleine und die große * Kachel nichts von diesem Programm. Die Namen stammen aus electron-builder * und sind nicht frei wählbar. * * Drei Größen sind quadratisch, die breite Kachel ist es nicht: Dort steht * das Zeichen mittig auf der Grundfarbe der Zeichnung, damit die Kachel * nicht halb durchsichtig ist. */ const KACHELN = [ { datei: 'StoreLogo.png', breite: 50, hoehe: 50 }, { datei: 'Square44x44Logo.png', breite: 44, hoehe: 44 }, { datei: 'Square150x150Logo.png', breite: 150, hoehe: 150 }, { datei: 'Wide310x150Logo.png', breite: 310, hoehe: 150 }, { datei: 'SmallTile.png', breite: 71, hoehe: 71 }, { datei: 'LargeTile.png', breite: 310, hoehe: 310 }, ]; /** Grundfarbe der Zeichnung – dieselbe wie das Feld in `symbol.svg`. */ const KACHELGRUND = '#17505f'; // ── Bilder holen ─────────────────────────────────────────────────────────── /** * Rastert die Zeichnung in allen verlangten Größen. * * Gezeichnet wird in eine Leinwand statt über einen Bildschirmabzug: Die * Leinwand liefert die Bildpunkte unmittelbar, ohne Umweg über Fenstergröße * und Geräteauflösung, und dieselbe Leinwand kodiert das PNG gleich mit. */ async function rastern(svg, groessen) { const browser = await chromium.launch(); try { const seite = await browser.newPage(); return await seite.evaluate( /* eslint-disable no-undef -- Dieser Rumpf läuft nicht hier, sondern im Browser: Playwright schickt ihn als Zeichenkette hinüber. `Image` und `document` gibt es dort, in Node nicht. Der Linter sieht die Datei als Node-Umgebung und kann den Wechsel nicht erkennen. */ async ({ svg: zeichnung, groessen: liste }) => { const adresse = URL.createObjectURL(new Blob([zeichnung], { type: 'image/svg+xml' })); const bild = new Image(); bild.src = adresse; await bild.decode(); const ergebnis = {}; for (const n of liste) { const leinwand = document.createElement('canvas'); leinwand.width = n; leinwand.height = n; const stift = leinwand.getContext('2d'); stift.clearRect(0, 0, n, n); stift.drawImage(bild, 0, 0, n, n); ergebnis[n] = { roh: Array.from(stift.getImageData(0, 0, n, n).data), png: leinwand.toDataURL('image/png').split(',')[1], }; } URL.revokeObjectURL(adresse); return ergebnis; }, /* eslint-enable no-undef */ { svg, groessen }, ); } finally { await browser.close(); } } /** * Rastert die Zeichnung auf Leinwände beliebigen Seitenverhältnisses. * * Für die breite Store-Kachel: Das Zeichen bleibt quadratisch und sitzt * mittig, der Rest bekommt die Grundfarbe der Zeichnung. Ein durchsichtiger * Rand sähe je nach Windows-Design anders aus als das Feld daneben. */ async function kachelnRastern(svg, kacheln, grund) { const browser = await chromium.launch(); try { const seite = await browser.newPage(); return await seite.evaluate( /* eslint-disable no-undef -- läuft im Browser, siehe `rastern`. */ async ({ svg: zeichnung, kacheln: liste, grund: farbe }) => { const adresse = URL.createObjectURL(new Blob([zeichnung], { type: 'image/svg+xml' })); const bild = new Image(); bild.src = adresse; await bild.decode(); const ergebnis = {}; for (const { datei, breite, hoehe } of liste) { const leinwand = document.createElement('canvas'); leinwand.width = breite; leinwand.height = hoehe; const stift = leinwand.getContext('2d'); const kante = Math.min(breite, hoehe); if (breite !== hoehe) { stift.fillStyle = farbe; stift.fillRect(0, 0, breite, hoehe); } else { stift.clearRect(0, 0, breite, hoehe); } stift.drawImage(bild, (breite - kante) / 2, (hoehe - kante) / 2, kante, kante); ergebnis[datei] = leinwand.toDataURL('image/png').split(',')[1]; } URL.revokeObjectURL(adresse); return ergebnis; }, /* eslint-enable no-undef */ { svg, kacheln, grund }, ); } finally { await browser.close(); } } // ── ICO zusammensetzen ───────────────────────────────────────────────────── /** * Ein Bild als unkomprimiertes BMP, wie es in einer ICO-Datei steht. * * Zwei Eigenheiten des Formats: Die angegebene Höhe ist die doppelte, weil * hinter den Bildpunkten noch eine Maske steht; und die Zeilen laufen von * unten nach oben. Die Maske bleibt leer — die Durchsichtigkeit steht schon * im Alphakanal jedes Bildpunkts. */ function alsBmp(n, roh) { const kopf = Buffer.alloc(40); kopf.writeUInt32LE(40, 0); // Größe dieses Kopfes kopf.writeInt32LE(n, 4); // Breite kopf.writeInt32LE(n * 2, 8); // Höhe, doppelt gezählt kopf.writeUInt16LE(1, 12); // Ebenen kopf.writeUInt16LE(32, 14); // Bit je Bildpunkt kopf.writeUInt32LE(0, 16); // keine Kompression const punkte = Buffer.alloc(n * n * 4); for (let zielzeile = 0; zielzeile < n; zielzeile += 1) { const quellzeile = n - 1 - zielzeile; for (let x = 0; x < n; x += 1) { const q = (quellzeile * n + x) * 4; const z = (zielzeile * n + x) * 4; punkte[z] = roh[q + 2]; // Blau punkte[z + 1] = roh[q + 1]; // Grün punkte[z + 2] = roh[q]; // Rot punkte[z + 3] = roh[q + 3]; // Deckung } } // Die Maske ist ein Bit je Bildpunkt, jede Zeile auf vier Byte aufgefüllt. const maske = Buffer.alloc(Math.ceil(n / 32) * 4 * n); kopf.writeUInt32LE(punkte.length + maske.length, 20); return Buffer.concat([kopf, punkte, maske]); } /** Setzt die einzelnen Bilder zu einer ICO-Datei zusammen. */ function alsIco(bilder) { const kopf = Buffer.alloc(6); kopf.writeUInt16LE(0, 0); // Vorbehalten kopf.writeUInt16LE(1, 2); // 1 = Symbol kopf.writeUInt16LE(bilder.length, 4); const verzeichnis = []; let versatz = 6 + 16 * bilder.length; for (const { groesse, daten } of bilder) { const eintrag = Buffer.alloc(16); // 256 wird als 0 geschrieben - ein Byte fasst die Zahl nicht. eintrag.writeUInt8(groesse === 256 ? 0 : groesse, 0); eintrag.writeUInt8(groesse === 256 ? 0 : groesse, 1); eintrag.writeUInt8(0, 2); // keine Farbtafel eintrag.writeUInt8(0, 3); // vorbehalten eintrag.writeUInt16LE(1, 4); // Ebenen eintrag.writeUInt16LE(32, 6); // Bit je Bildpunkt eintrag.writeUInt32LE(daten.length, 8); eintrag.writeUInt32LE(versatz, 12); verzeichnis.push(eintrag); versatz += daten.length; } return Buffer.concat([kopf, ...verzeichnis, ...bilder.map((b) => b.daten)]); } // ── Ablauf ───────────────────────────────────────────────────────────────── const svg = readFileSync(QUELLE, 'utf8'); const alle = [...GROESSEN, PNG_GROESSE]; const bilder = await rastern(svg, alle); const eintraege = GROESSEN.map((groesse) => ({ groesse, daten: groesse >= AB_PNG ? Buffer.from(bilder[groesse].png, 'base64') : alsBmp(groesse, bilder[groesse].roh), })); writeFileSync(ICO, alsIco(eintraege)); writeFileSync(PNG, Buffer.from(bilder[PNG_GROESSE].png, 'base64')); /* Die vier Pflichtgrafiken des Store-Pakets. Ohne sie greift electron-builder still zu seinen Beispielbildern – siehe `KACHELN`. */ mkdirSync(APPX, { recursive: true }); const kachelbilder = await kachelnRastern(svg, KACHELN, KACHELGRUND); for (const { datei } of KACHELN) { writeFileSync(join(APPX, datei), Buffer.from(kachelbilder[datei], 'base64')); } const pruefsumme = createHash('sha256').update(svg, 'utf8').digest('hex'); writeFileSync( BUCH, `${JSON.stringify( { hinweis: 'Erzeugt von tools/symbol-bauen.mjs. Nicht von Hand ändern — ' + 'die Prüfsumme belegt, dass icon.ico und icon.png zu symbol.svg passen.', quelle: 'symbol.svg', pruefsumme, groessen: GROESSEN, pngGroesse: PNG_GROESSE, kacheln: KACHELN, }, null, 2, )}\n`, ); const summe = eintraege.reduce((s, e) => s + e.daten.length, 0); console.log( `icon.ico: ${String(GROESSEN.length)} Größen (${GROESSEN.join(', ')}), ${String(Math.round(summe / 1024))} KiB`, ); console.log(`icon.png: ${String(PNG_GROESSE)} x ${String(PNG_GROESSE)}`); console.log( `appx/: ${KACHELN.map((k) => `${k.datei} ${String(k.breite)}x${String(k.hoehe)}`).join(', ')}`, ); console.log(`symbol.json: Prüfsumme ${pruefsumme.slice(0, 12)}…`);