Files
AutistMask/script/lib/icons.js
T
sneak 189bd3f4ba
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
chore: draw the toolbar icons in-tree with make icons (closes #378)
script/lib/icons.js draws the mark from geometry read back out of the
committed PNGs, since the coordinates were never recorded, and writes each
PNG with node's own zlib. `make icons` runs it and leaves alone a file that
already holds the drawn image.

tests/icons.test.js requires each committed file to hold exactly that
image: the same IHDR and every pixel. The compressed bytes are not
compared, because node's bundled zlib does not compress as the stock zlib
that made the committed files did; the decision is recorded on the issue.

build.js copies the manifests from MANIFEST_SOURCES instead of naming the
two paths a second time.

Model: opus-5-5
2026-10-07 03:48:36 +00:00

204 lines
7.1 KiB
JavaScript

// Draws the toolbar icon and writes it to every file manifest/chrome.json
// declares under "icons". Run by `make icons`; tests/icons.test.js checks that
// the committed files hold exactly the image this draws.
//
// The icon is a dark-navy rounded square carrying a teal triangle with a
// smaller triangle cut out of it. Every coordinate below is a fraction of the
// icon's side, so one drawing serves every size. A pixel's colour is the
// average of an 8x8 grid of samples, one at the centre of each cell, which
// smooths the edges.
//
// The PNG is written here rather than by a library: RGBA at 8 bits per
// channel, filter type 0 (none) on every row, one IDAT chunk compressed by
// node's zlib at level 9. The committed files were compressed by stock zlib,
// which node's bundled zlib does not reproduce byte for byte, so the image is
// compared rather than the file: the IHDR and every pixel.
"use strict";
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const { icons } = require("../../manifest/chrome.json");
const NAVY = [0x10, 0x1a, 0x2e];
const TEAL = [0x35, 0xe0, 0xc2];
const CORNER_RADIUS = 0.22;
const OUTER_TRIANGLE = [
[0.5, 0.15],
[0.115, 0.855],
[0.885, 0.855],
];
const INNER_TRIANGLE = [
[0.5, 0.4],
[0.29, 0.7],
[0.71, 0.7],
];
const SAMPLES_PER_SIDE = 8;
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
// Points are in pixels from the icon's top-left corner.
function insideRoundedSquare(x, y, size) {
const r = CORNER_RADIUS * size;
// How far the point is past the start of a corner's curve, on each axis.
const dx = Math.max(r - x, 0, x - (size - r));
const dy = Math.max(r - y, 0, y - (size - r));
return dx * dx + dy * dy <= r * r;
}
// Inside or on an edge: the point is not on opposite sides of two edges.
function insideTriangle(x, y, [a, b, c]) {
const side = (p, q) =>
(q[0] - p[0]) * (y - p[1]) - (q[1] - p[1]) * (x - p[0]);
const sides = [side(a, b), side(b, c), side(c, a)];
return !(sides.some((s) => s < 0) && sides.some((s) => s > 0));
}
// Rounds to the nearest integer and a half to the even neighbour, as the
// committed icons were made. Math.round takes a half up, which would change
// some pixels by one.
function roundHalfToEven(v) {
const down = Math.floor(v);
if (v - down !== 0.5) {
return Math.round(v);
}
return down % 2 === 0 ? down : down + 1;
}
// The RGBA bytes of the pixel whose top-left corner is (px, py).
function pixel(px, py, size, outer, inner) {
let inSquare = 0;
let inTeal = 0;
for (let j = 0; j < SAMPLES_PER_SIDE; j++) {
const y = py + (j + 0.5) / SAMPLES_PER_SIDE;
for (let i = 0; i < SAMPLES_PER_SIDE; i++) {
const x = px + (i + 0.5) / SAMPLES_PER_SIDE;
if (!insideRoundedSquare(x, y, size)) {
continue;
}
inSquare++;
if (insideTriangle(x, y, outer) && !insideTriangle(x, y, inner)) {
inTeal++;
}
}
}
if (inSquare === 0) {
return [0, 0, 0, 0];
}
const colour = NAVY.map((navy, k) =>
roundHalfToEven(navy + ((TEAL[k] - navy) * inTeal) / inSquare),
);
const alpha = roundHalfToEven((255 * inSquare) / SAMPLES_PER_SIDE ** 2);
return [...colour, alpha];
}
// A PNG chunk: the data's length, the type, the data, then the CRC-32 of the
// type and the data.
function chunk(type, data) {
const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data]);
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(zlib.crc32(typeAndData));
return Buffer.concat([length, typeAndData, crc]);
}
// The complete PNG file for the icon at `size` pixels square.
function drawIcon(size) {
const toPixels = (corners) => corners.map(([x, y]) => [x * size, y * size]);
const outer = toPixels(OUTER_TRIANGLE);
const inner = toPixels(INNER_TRIANGLE);
const rows = [];
for (let py = 0; py < size; py++) {
const row = [0]; // filter type 0: the row's bytes are stored as they are
for (let px = 0; px < size; px++) {
row.push(...pixel(px, py, size, outer, inner));
}
rows.push(Buffer.from(row));
}
const header = Buffer.alloc(13); // compression, filter, interlace: all 0
header.writeUInt32BE(size, 0); // width
header.writeUInt32BE(size, 4); // height
header[8] = 8; // bits per channel
header[9] = 6; // colour type: RGBA
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows), { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// The image in a PNG: its IHDR data, and its rows after decompression, each
// row's filter type byte first. With filter type 0 on every row, as drawIcon
// writes, the rows are the pixels themselves; a file using another filter does
// not match even where its pixels do. Refuses a file that is not a PNG, a chunk
// whose CRC-32 is wrong, and any chunk but IHDR, IDAT and IEND, rather than
// ignoring what it cannot compare.
function decodePng(png) {
if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) {
throw new Error("not a PNG");
}
let header = null;
const idat = [];
for (let pos = 8; pos < png.length; ) {
const length = png.readUInt32BE(pos);
const typeAndData = png.subarray(pos + 4, pos + 8 + length);
const type = typeAndData.toString("ascii", 0, 4);
if (zlib.crc32(typeAndData) !== png.readUInt32BE(pos + 8 + length)) {
throw new Error(`the ${type} chunk fails its CRC-32`);
}
if (type === "IHDR") {
header = typeAndData.subarray(4);
} else if (type === "IDAT") {
idat.push(typeAndData.subarray(4));
} else if (type !== "IEND") {
throw new Error(`unexpected ${type} chunk`);
}
pos += 12 + length;
}
if (header === null) {
throw new Error("no IHDR chunk");
}
return { header, rows: zlib.inflateSync(Buffer.concat(idat)) };
}
// True when `file` already holds the image in `png`. A file that is missing or
// cannot be read as a PNG does not, and is written over.
function holdsSameImage(file, png) {
let existing;
try {
existing = decodePng(fs.readFileSync(file));
} catch {
return false;
}
const drawn = decodePng(png);
return (
existing.header.equals(drawn.header) && existing.rows.equals(drawn.rows)
);
}
// A file already holding the drawn image is left alone, so running this does
// not rewrite the committed icons with node's compression.
if (require.main === module) {
for (const [size, file] of Object.entries(icons)) {
const target = path.join(__dirname, "..", "..", file);
const png = drawIcon(Number(size));
if (holdsSameImage(target, png)) {
console.log(`icons: ${file} already holds this image`);
continue;
}
fs.writeFileSync(target, png);
console.log(`icons: wrote ${file}`);
}
}
module.exports = { drawIcon, decodePng };