// Renders the icon PNGs from brand/sticker.svg (and its round variant for // circular-crop avatar slots) so the logo has a single source of truth. // Plain node plus a headless Chrome; no npm dependencies. // // Rasterization goes through scripts/rasterize.mjs: each size loads the SVG // (inlined as a data URI, given explicit width/height so it rasterizes // crisply) into a canvas in a headless Chrome page, which reports the PNG // plus its own opaque-pixel count. The script then verifies the IHDR // dimensions and that enough of the tile is actually painted, so a // misrender fails loudly instead of shipping. // // Usage: node scripts/render-icons.mjs (also: npm run icons) // Set CHROME to point at a specific Chrome/Chromium binary. import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { rasterize } from './rasterize.mjs' // source SVG -> size -> destinations, relative to the repo root const JOBS = [ { svg: 'brand/sticker.svg', outputs: { 16: ['public/icons/icon16.png'], 32: ['public/icons/icon32.png', 'web/public/icons/icon32.png'], 48: ['public/icons/icon48.png'], 128: ['public/icons/icon128.png', 'web/public/icons/icon128.png'], // apple-touch-icon for the frontpage; not shipped in the extension. 180: ['web/public/icons/icon180.png'], // The big boy (store listing / avatar use); not shipped in the extension. 480: ['brand/icon480.png'], }, }, { // The dev channel's tiles: the same sticker, colors inverted, so a dev // install is obvious in the toolbar next to the store one. Not in // public/, so they never reach a production build; scripts/dev-channel.mjs // copies them over dist/icons/ when the dev channel is on. svg: 'brand/sticker.svg', label: 'dev-', transform: invertHexColors, outputs: { 16: ['brand/icons-dev/icon16.png'], 32: ['brand/icons-dev/icon32.png'], 48: ['brand/icons-dev/icon48.png'], 128: ['brand/icons-dev/icon128.png'], }, }, { // For avatar slots that crop uploads to a circle; not shipped anywhere. svg: 'brand/sticker-round.svg', outputs: { 128: ['brand/icon128-round.png'], 480: ['brand/icon480-round.png'], }, }, ] // The square sticker fills most of its tile and the round one covers pi/4 // of it; a healthy render is well above this. A blank or partial paint // (the failure modes we've actually seen) is far below it. const MIN_OPAQUE_FRACTION = 0.3 /** Width and height from a PNG's IHDR chunk. Takes any Uint8Array. */ export function pngSize(bytes) { const magic = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] if (bytes.length < 24 || !magic.every((b, i) => bytes[i] === b)) { throw new Error('not a PNG') } const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) return { width: dv.getUint32(16), height: dv.getUint32(20) } } /** * Photographic negative of every hex color in an SVG: each channel becomes * 255 - c. Three-digit hex expands first. Keywords like `none` are left * alone, so `fill="none"` stays unfilled. Inverting twice is the identity, * which is what the test leans on. */ export function invertHexColors(svg) { return svg.replace(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/g, (_, hex) => { const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex const channels = full .match(/../g) .map((pair) => (255 - parseInt(pair, 16)).toString(16).padStart(2, '0')) return `#${channels.join('')}` }) } export function findChrome() { const candidates = process.env.CHROME ? [process.env.CHROME] : ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'] for (const bin of candidates) { try { execFileSync(bin, ['--version'], { stdio: 'ignore' }) return bin } catch { // keep looking } } throw new Error('no Chrome found; set CHROME=/path/to/chrome') } function main() { const chrome = findChrome() const work = mkdtempSync(join(tmpdir(), 'substandard-icons-')) try { for (const { svg, outputs, label = '', transform } of JOBS) { const source = transform ? transform(readFileSync(resolve(svg), 'utf8')) : readFileSync(resolve(svg), 'utf8') for (const [size, dests] of Object.entries(outputs)) { const n = Number(size) // The label keeps two jobs rendering the same source at the same // size from sharing a scratch file inside `work`. const name = label + dests[0].split('/').pop().replace(/\.png$/, '') const svgText = source.replace(' ${dests.join(', ')}`, ) } } } finally { rmSync(work, { recursive: true, force: true }) } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main() }