// Erzeugt das EuerChat-Logo (Icon, Wortmarke, Installer-Bilder) aus SVG und rendert es mit Edge (Headless). // Aufruf: node tools/make-branding.mjs 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 FONT = "'Segoe UI Black','Arial Black',Verdana,sans-serif"; // --------------------------------------------------------------------------- // SVG-Bausteine // --------------------------------------------------------------------------- /** Rotes Orb-Symbol mit Chrom-Mikrofon und Chat-Blase (512x512). `p` macht die IDs eindeutig. */ function iconInner(p) { const slots = []; for (let y = 144; y <= 268; y += 17) { slots.push(``); slots.push(``); } return ` ${slots.join('')} `; } function iconSvg(size) { return `${iconInner('i')}`; } /** Chrom-Schriftzug: dunkle Kontur, silberner Rand, helle Fuellung. */ function chromeText(p, x, y, size, text, width) { const common = `x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" text-anchor="middle" textLength="${width}" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round"`; return ` ${text} ${text} ${text}`; } function textDefs(p) { return ` `; } /** Breites Logo im Stil des euer.radio-Logos: [euer.tv] (Orb) [chat] in einer roten Pille. */ function wideSvg(measure) { const size = 170; const wL = Math.round(measure.left); const wR = Math.round(measure.right); const orb = 540; const gap = 60; const padEnd = 150; const width = padEnd + wL + gap + orb + gap + wR + padEnd; const height = 560; const cx = padEnd + wL + gap + orb / 2; const baseline = 335; return { width, height, svg: ` ${textDefs('w')} ${chromeText('w', padEnd + wL / 2 + 20, baseline, size, 'euer.tv', wL)} ${chromeText('w', width - padEnd - wR / 2 - 20, baseline, size, 'chat', wR)} ${iconInner('w')} `, }; } /** Hochformat fuer die Willkommensseite des Installers (164x314). */ function sidebarSvg() { return ` ${textDefs('s')} ${iconInner('s')} ${chromeText('s', 82, 214, 31, 'euer.tv', 126)} ${chromeText('s', 82, 262, 40, 'chat', 92)} `; } function headerSvg(wide) { const w = 150, h = 57; const scale = Math.min(w / wide.width, h / wide.height); return ` ${wide.svg.replace(/^]*>/, '').replace(/<\/svg>\s*$/, '')} `; } // --------------------------------------------------------------------------- // Rendering mit Edge (Chrome DevTools Protocol) // --------------------------------------------------------------------------- async function startEdge() { const port = 9341; const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'euerchat-brand-')); const proc = spawn(EDGE, [ '--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, '--hide-scrollbars', '--force-device-scale-factor=1', '--disable-gpu', 'about:blank', ], { stdio: 'ignore' }); let page; for (let i = 0; i < 50 && !page; i++) { await new Promise((r) => setTimeout(r, 200)); try { const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); page = targets.find((t) => t.type === 'page'); } catch { /* Edge startet noch */ } } if (!page) throw new Error('Edge-Debugport nicht erreichbar'); const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((r) => (ws.onopen = r)); let id = 0; const pending = new Map(); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id); } }; const send = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); await send('Page.enable'); await send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }); return { send, async html(markup, width, height) { await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); const doc = `${markup}`; await send('Page.navigate', { url: 'data:text/html;base64,' + Buffer.from(doc).toString('base64') }); await new Promise((r) => setTimeout(r, 500)); await send('Runtime.evaluate', { expression: 'document.fonts.ready.then(()=>1)', awaitPromise: true }); }, async png() { const r = await send('Page.captureScreenshot', { format: 'png', fromSurface: true }); return Buffer.from(r.data, 'base64'); }, async eval(expression) { const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); return r.result?.value; }, close() { ws.close(); proc.kill(); }, }; } // --------------------------------------------------------------------------- // ICO / BMP // --------------------------------------------------------------------------- /** ICO mit PNG-Frames, groesste zuerst (Tauri nimmt das erste Bild als Fenstersymbol). */ function makeIco(frames) { const sorted = [...frames].sort((a, b) => b.size - a.size); const header = Buffer.alloc(6); header.writeUInt16LE(1, 2); header.writeUInt16LE(sorted.length, 4); let offset = 6 + sorted.length * 16; const entries = sorted.map((f) => { const e = Buffer.alloc(16); e[0] = f.size >= 256 ? 0 : f.size; e[1] = f.size >= 256 ? 0 : f.size; e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6); e.writeUInt32LE(f.png.length, 8); e.writeUInt32LE(offset, 12); offset += f.png.length; return e; }); return Buffer.concat([header, ...entries, ...sorted.map((f) => f.png)]); } 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 (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 }; } /** 24-Bit-BMP (NSIS kann nur BMP), Transparenz wird auf `bg` gemischt. */ function pngToBmp(png, bg = [255, 255, 255]) { const { width, height, bpp, px } = decodePng(png); const rowSize = Math.ceil((width * 3) / 4) * 4; const out = Buffer.alloc(54 + rowSize * height); out.write('BM', 0); out.writeUInt32LE(out.length, 2); out.writeUInt32LE(54, 10); out.writeUInt32LE(40, 14); out.writeInt32LE(width, 18); out.writeInt32LE(height, 22); out.writeUInt16LE(1, 26); out.writeUInt16LE(24, 28); out.writeUInt32LE(rowSize * height, 34); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * bpp; const alpha = bpp === 4 ? px[i + 3] / 255 : 1; const o = 54 + (height - 1 - y) * rowSize + x * 3; for (let c = 0; c < 3; c++) out[o + c] = Math.round(px[i + 2 - c] * alpha + bg[2 - c] * (1 - alpha)); } } return out; } // --------------------------------------------------------------------------- const edge = await startEdge(); try { // Textbreiten der Wortmarke messen, damit nichts verzerrt wird await edge.html(`euer.tvchat`, 400, 200); const measure = { left: await edge.eval("document.getElementById('a').getComputedTextLength()"), right: await edge.eval("document.getElementById('b').getComputedTextLength()"), }; console.log('Textbreiten:', measure); const render = async (svg, w, h) => { await edge.html(svg, w, h); return edge.png(); }; const out = { icons: path.join(root, 'src-tauri', 'icons'), assets: path.join(root, 'assets'), installer: path.join(root, 'installer'), ui: path.join(root, 'ui'), }; for (const d of Object.values(out)) fs.mkdirSync(d, { recursive: true }); const frames = []; for (const size of [256, 128, 64, 48, 32, 24, 16]) frames.push({ size, png: await render(iconSvg(size), size, size) }); fs.writeFileSync(path.join(out.icons, 'icon.ico'), makeIco(frames)); const bySize = (s) => frames.find((f) => f.size === s).png; fs.writeFileSync(path.join(out.icons, '32x32.png'), bySize(32)); fs.writeFileSync(path.join(out.icons, '128x128.png'), bySize(128)); fs.writeFileSync(path.join(out.icons, 'icon.png'), await render(iconSvg(512), 512, 512)); fs.writeFileSync(path.join(out.ui, 'logo.png'), bySize(128)); const wide = wideSvg(measure); fs.writeFileSync(path.join(out.assets, 'logo-wide.png'), await render(wide.svg, wide.width, wide.height)); fs.writeFileSync(path.join(out.assets, 'logo-icon.svg'), iconSvg(512)); fs.writeFileSync(path.join(out.assets, 'logo-wide.svg'), wide.svg); fs.writeFileSync(path.join(out.installer, 'sidebar.bmp'), pngToBmp(await render(sidebarSvg(), 164, 314), [5, 0, 0])); fs.writeFileSync(path.join(out.installer, 'header.bmp'), pngToBmp(await render(headerSvg(wide), 150, 57))); console.log('Fertig:', Object.values(out).join('\n ')); } finally { edge.close(); }