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
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev
|
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons clean dev
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -104,6 +104,13 @@ build-debug:
|
|||||||
vendor-blocklist:
|
vendor-blocklist:
|
||||||
@script/vendor-blocklist
|
@script/vendor-blocklist
|
||||||
|
|
||||||
|
# Redraw the toolbar icons in icons/ from script/lib/icons.js, at every size
|
||||||
|
# manifest/chrome.json declares, leaving alone a file that already holds the
|
||||||
|
# drawn image. Commit the result with the drawing: tests/icons.test.js fails
|
||||||
|
# while the two disagree.
|
||||||
|
icons:
|
||||||
|
@node script/lib/icons.js
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@rm -rf dist/ release/
|
@rm -rf dist/ release/
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,11 @@ The Makefile shims to those. It also carries a few targets that have no
|
|||||||
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
|
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
|
||||||
debug build, and keeping its `dist/` on failure (see
|
debug build, and keeping its `dist/` on failure (see
|
||||||
[Debug Builds](#debug-builds))
|
[Debug Builds](#debug-builds))
|
||||||
|
- `make icons` — redraw the toolbar icons in `icons/` from
|
||||||
|
`script/lib/icons.js`, at every size `manifest/chrome.json` declares. A file
|
||||||
|
that already holds the drawn image, the same IHDR and every pixel, is left
|
||||||
|
untouched. Commit the files with the drawing: `make check` fails while their
|
||||||
|
image differs from what it draws
|
||||||
- `make clean` — remove `dist/` and `release/`
|
- `make clean` — remove `dist/` and `release/`
|
||||||
- `make dev` — run the build `make build` runs, without the checks that follow
|
- `make dev` — run the build `make build` runs, without the checks that follow
|
||||||
it, then run it again after every change to a file under `src/`, `manifest/`
|
it, then run it again after every change to a file under `src/`, `manifest/`
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
|
||||||
|
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
|
||||||
|
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
|
||||||
|
own `zlib`, no new dependency, leaving 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 them as the stock
|
||||||
|
zlib that made the committed files did. `build.js` now copies each manifest
|
||||||
|
from the same path `copyIcons()` reads its icons from.
|
||||||
|
|
||||||
- 2026-10-07: The README's end-to-end limits now match what the two browser
|
- 2026-10-07: The README's end-to-end limits now match what the two browser
|
||||||
suites do to the extension
|
suites do to the extension
|
||||||
([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close`
|
([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close`
|
||||||
|
|||||||
@@ -588,15 +588,10 @@ async function build() {
|
|||||||
copyIcons(distDir);
|
copyIcons(distDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy manifests
|
// copy manifests, the same files copyIcons() read
|
||||||
copyEmitted(
|
for (const [distDir, manifestPath] of MANIFEST_SOURCES) {
|
||||||
path.join(__dirname, "manifest", "chrome.json"),
|
copyEmitted(manifestPath, path.join(distDir, "manifest.json"));
|
||||||
path.join(DIST_CHROME, "manifest.json"),
|
}
|
||||||
);
|
|
||||||
copyEmitted(
|
|
||||||
path.join(__dirname, "manifest", "firefox.json"),
|
|
||||||
path.join(DIST_FIREFOX, "manifest.json"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assertForbiddenTableCovered(forbiddenRecord);
|
assertForbiddenTableCovered(forbiddenRecord);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// 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 };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// The toolbar icons in icons/ are drawn by script/lib/icons.js (`make icons`).
|
||||||
|
// Each committed file must hold exactly the image it draws, the same IHDR and
|
||||||
|
// every pixel, so the drawing and the files cannot drift apart. The compressed
|
||||||
|
// bytes are not compared: the committed files were compressed by stock zlib,
|
||||||
|
// and node's bundled zlib compresses the same pixels differently.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { icons } = require("../manifest/chrome.json");
|
||||||
|
const { drawIcon, decodePng } = require("../script/lib/icons");
|
||||||
|
|
||||||
|
describe("toolbar icons", () => {
|
||||||
|
test.each(Object.entries(icons))(
|
||||||
|
"the %spx icon %s holds the image script/lib/icons.js draws",
|
||||||
|
(size, file) => {
|
||||||
|
const side = Number(size);
|
||||||
|
const committed = decodePng(
|
||||||
|
fs.readFileSync(path.join(__dirname, "..", file)),
|
||||||
|
);
|
||||||
|
const drawn = decodePng(drawIcon(side));
|
||||||
|
|
||||||
|
expect(committed.header).toEqual(drawn.header);
|
||||||
|
// Each row is its filter type byte, then 4 bytes per pixel.
|
||||||
|
expect(drawn.rows.length).toBe(side * (side * 4 + 1));
|
||||||
|
expect(committed.rows.length).toBe(drawn.rows.length);
|
||||||
|
// The offset of the first byte that differs, not a diff of all.
|
||||||
|
expect(
|
||||||
|
committed.rows.findIndex((byte, i) => byte !== drawn.rows[i]),
|
||||||
|
).toBe(-1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user