Every icon is rendered from web/scripts/logo-source.png by scripts/icons.mjs: trimmed to the artwork's own edge, transparent corners for the browser icons, a dark ground from the artwork's palette for iOS (which paints transparency black) and for Android's maskable icon, where the whole artwork sits inside the 80% safe circle. The login screen no longer crops the logo to a circle or repeats the wordmark. Service worker cache bumped so installed apps fetch the new files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
69 lines
3.9 KiB
JavaScript
69 lines
3.9 KiB
JavaScript
// Renders the PWA icon set and the login logo from scripts/logo-source.png, with node-canvas
|
||
// (borrowed from ../../calorie-counter/node_modules). node scripts/icons.mjs
|
||
//
|
||
// The artwork is a rounded square on a transparent background, a little off-centre in its
|
||
// file. It is trimmed to its own edge, then drawn three ways:
|
||
// icon-192 / icon-512 / logo the artwork alone, transparent corners, filling the square
|
||
// apple-touch-icon iOS paints transparency black and cuts its own, tighter
|
||
// corners: a dark ground from the artwork's own palette, the
|
||
// artwork inset a little so its neon ring clears that cut
|
||
// icon-512-maskable Android may crop to a circle 80% across: the whole artwork
|
||
// sits inside that circle, on the same dark ground to the edge
|
||
import { createRequire } from 'node:module'
|
||
import { writeFileSync } from 'node:fs'
|
||
const require = createRequire(import.meta.url)
|
||
const { createCanvas, loadImage } = require('/home/core/projects/calorie-counter/node_modules/canvas')
|
||
|
||
const img = await loadImage(new URL('./logo-source.png', import.meta.url).pathname)
|
||
const src = createCanvas(img.width, img.height), sg = src.getContext('2d')
|
||
sg.drawImage(img, 0, 0)
|
||
const px = sg.getImageData(0, 0, img.width, img.height), d = px.data
|
||
// The export left the artwork at alpha 252 and a stray alpha-1 speck outside it: make what
|
||
// is there fully there, what isn't fully gone, and keep the anti-aliased edge between.
|
||
let x0 = img.width, y0 = img.height, x1 = 0, y1 = 0
|
||
for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
|
||
const i = (y * img.width + x) * 4 + 3
|
||
if (d[i] >= 240) d[i] = 255; else if (d[i] < 8) d[i] = 0
|
||
if (d[i] > 128) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y }
|
||
}
|
||
sg.putImageData(px, 0, 0)
|
||
// a square around the artwork's own centre, as wide as its longer side: never stretched
|
||
const side = Math.max(x1 - x0, y1 - y0) + 1, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2
|
||
const art = createCanvas(side, side)
|
||
art.getContext('2d').drawImage(src, Math.round(cx - side / 2), Math.round(cy - side / 2), side, side, 0, 0, side, side)
|
||
|
||
// Shrinking six-fold in one step aliases the fine lines; halving until close does not.
|
||
function shrink(from, size) {
|
||
let cur = from
|
||
while (cur.width / 2 >= size) {
|
||
const next = createCanvas(Math.ceil(cur.width / 2), Math.ceil(cur.height / 2)), g = next.getContext('2d')
|
||
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; g.quality = 'best'; g.patternQuality = 'best'
|
||
g.drawImage(cur, 0, 0, next.width, next.height)
|
||
cur = next
|
||
}
|
||
return cur
|
||
}
|
||
|
||
function draw(size, { scale = 1, ground = false } = {}) {
|
||
const c = createCanvas(size, size), g = c.getContext('2d')
|
||
if (ground) { // the artwork's own night-green, lit a little behind the middle
|
||
const bg = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size * 0.72)
|
||
bg.addColorStop(0, '#0b2e2a'); bg.addColorStop(1, '#03110f')
|
||
g.fillStyle = bg; g.fillRect(0, 0, size, size)
|
||
}
|
||
const w = Math.round(size * scale), from = shrink(art, w)
|
||
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; g.quality = 'best'; g.patternQuality = 'best'
|
||
g.drawImage(from, (size - w) / 2, (size - w) / 2, w, w)
|
||
return c.toBuffer('image/png', { compressionLevel: 9 })
|
||
}
|
||
|
||
const out = {
|
||
'icon-192.png': draw(192),
|
||
'icon-512.png': draw(512),
|
||
'logo.png': draw(480),
|
||
'apple-touch-icon.png': draw(180, { scale: 0.95, ground: true }),
|
||
'icon-512-maskable.png': draw(512, { scale: 0.68, ground: true }),
|
||
}
|
||
for (const [name, buf] of Object.entries(out)) { writeFileSync(new URL('../public/icons/' + name, import.meta.url).pathname, buf); console.log(name, Math.round(buf.length / 1024) + ' KB') }
|
||
console.log(`artwork ${x1 - x0 + 1}×${y1 - y0 + 1} at ${x0},${y0} → ${side} px square`)
|