Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
5.8 kB · 150 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151// 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 rootconst 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('<svg ', `<svg width="${n}" height="${n}" `) const { opaque, png } = rasterize(chrome, work, name, svgText, n, n) const dim = pngSize(png) if (dim.width !== n || dim.height !== n) { throw new Error(`${name}: rendered ${dim.width}x${dim.height}`) } const fraction = opaque / (n * n) if (fraction < MIN_OPAQUE_FRACTION) { throw new Error( `${name}: only ${(fraction * 100).toFixed(1)}% opaque — misrender`, ) } for (const dest of dests) { mkdirSync(dirname(resolve(dest)), { recursive: true }) writeFileSync(resolve(dest), png) } console.log( `${name}: ${png.length} bytes, ${(fraction * 100).toFixed(0)}% opaque -> ${dests.join(', ')}`, ) } } } finally { rmSync(work, { recursive: true, force: true }) }}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main()}