// Erzeugt alle Icons und Installer-Bilder aus dem Original-Logo assets/logo-original.png (unveraendert verwendet). // Quadratische Icons zeigen den runden Mikrofon-Ball aus dem Logo, breite Flaechen das komplette Logo. // Aufruf: node tools/make-branding.mjs (rendert mit Microsoft Edge im Headless-Modus) import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import zlib from 'node:zlib'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const EDGE = [ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', ].find(fs.existsSync); if (!EDGE) throw new Error('Microsoft Edge nicht gefunden'); const sourcePath = path.join(root, 'assets', 'logo-original.png'); const sourcePng = fs.readFileSync(sourcePath); const dataUrl = 'data:image/png;base64,' + sourcePng.toString('base64'); // --------------------------------------------------------------------------- // PNG lesen (nur 8 Bit RGB/RGBA ohne Interlacing, wie vom Logo und von Edge geliefert) // --------------------------------------------------------------------------- function decodePng(buf) { let pos = 8; let width = 0, height = 0, colorType = 0; const idat = []; while (pos < buf.length) { const len = buf.readUInt32BE(pos); const type = buf.toString('ascii', pos + 4, pos + 8); const data = buf.subarray(pos + 8, pos + 8 + len); if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9]; if (data[8] !== 8 || data[12] !== 0 || (colorType !== 2 && colorType !== 6)) throw new Error('PNG-Format nicht unterstuetzt'); } if (type === 'IDAT') idat.push(data); pos += 12 + len; } const bpp = colorType === 6 ? 4 : 3; const raw = zlib.inflateSync(Buffer.concat(idat)); const stride = width * bpp; const px = Buffer.alloc(height * stride); for (let y = 0; y < height; y++) { const filter = raw[y * (stride + 1)]; for (let x = 0; x < stride; x++) { const v = raw[y * (stride + 1) + 1 + x]; const a = x >= bpp ? px[y * stride + x - bpp] : 0; const b = y > 0 ? px[(y - 1) * stride + x] : 0; const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0; let out; if (filter === 0) out = v; else if (filter === 1) out = v + a; else if (filter === 2) out = v + b; else if (filter === 3) out = v + ((a + b) >> 1); else { const p = a + b - c; const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); out = v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); } px[y * stride + x] = out & 255; } } return { width, height, bpp, px }; } /** Findet den dunklen Ring um den roten Ball: senkrecht durch die Mitte (Durchmesser) und nahe dem oberen Rand (Mittelpunkt). */ function findOrb(logo) { const { width, height, bpp, px } = logo; const isDark = (x, y) => { const i = (y * width + x) * bpp; return (bpp === 4 ? px[i + 3] : 255) > 200 && Math.max(px[i], px[i + 1], px[i + 2]) < 70; }; const cxEst = Math.round(width / 2); let top = -1, bottom = -1; for (let y = 0; y < height; y++) { if (isDark(cxEst, y)) { if (top < 0) top = y; bottom = y; } } const r = (bottom - top) / 2; const cy = (top + bottom) / 2; // Die Messzeile nahe dem oberen Rand schneidet nur den Ring (links und rechts), nicht den Text. let left = -1, right = -1; for (let x = cxEst - 330; x <= cxEst + 330; x++) { if (isDark(x, top + 40)) { if (left < 0) left = x; right = x; } } const cx = (left + right) / 2; if (top < 0 || left < 0 || r < 200 || r > 300 || Math.abs(cx - cxEst) > 30) { throw new Error(`Ball im Logo nicht erkannt (top=${top}, bottom=${bottom}, left=${left}, right=${right})`); } return { cx, cy, r }; } // --------------------------------------------------------------------------- // HTML-Bausteine (werden von Edge gerendert) // --------------------------------------------------------------------------- const logo = decodePng(sourcePng); const orb = findOrb(logo); // Im Original liegen Teile der Buchstaben ("r" von euer und "ra" von radio) ueber dem Ball. // Fuer die runden Icons werden sie im Textband senkrecht zwischen den sauberen Pixeln darueber und darunter aufgefuellt; // das Mikrofon bleibt unangetastet. Das breite Logo wird nie retuschiert. const RETOUCH_JS = `(async () => { const img = new Image(); img.src = ${JSON.stringify(dataUrl)}; await img.decode(); const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const ctx = c.getContext('2d', { willReadFrequently: true }); ctx.drawImage(img, 0, 0); const d = ctx.getImageData(0, 0, c.width, c.height); const p = d.data; const W = c.width; const O = ${JSON.stringify(orb)}; const s = O.r / 271; const YT = Math.round(O.cy - 68 * s), Y0 = YT + 6, YB = Math.round(O.cy + 98 * s), Y1 = YB - 6; const micL = O.cx - 126 * s, micR = O.cx + 82 * s, clearX = O.cx + 114 * s; const at = (x, y) => { const i = (y * W + x) * 4; return [p[i], p[i + 1], p[i + 2], p[i + 3]]; }; for (let x = Math.floor(O.cx - O.r) - 5; x <= Math.ceil(O.cx + O.r) + 5; x++) { if (x >= micL && x < micR) continue; const a = at(x >= micR && x < clearX ? Math.round(clearX) : x, YT), b = at(x, YB); // Neben dem Mikrofonkopf beginnt der Buchstabe erst weiter unten; den Kopfrand nicht anschneiden. const yFrom = x >= micR && x < clearX ? Math.round(O.cy - 38 * s) : Y0; for (let y = yFrom; y <= Y1; y++) { const dx = x - O.cx, dy = y - O.cy; if (dx * dx + dy * dy > (O.r + 5) * (O.r + 5)) continue; const t = (y - YT) / (YB - YT), i = (y * W + x) * 4; for (let k = 0; k < 4; k++) p[i + k] = Math.round(a[k] + (b[k] - a[k]) * t); } } ctx.putImageData(d, 0, 0); return c.toDataURL('image/png'); })()`; // Quelle fuer die runden Icons; wird nach der Retusche ersetzt. let orbUrl = dataUrl; /** Runder Ausschnitt (Ball mit Mikrofon) in `size` Pixeln. */ function orbHtml(size, extra = '') { const r = orb.r + 3; const k = size / (2 * r); return `
`; } const wideHtml = (w, h, bg = 'transparent') => `