diff --git a/Makefile b/Makefile index 59e2422..3103f8b 100644 --- a/Makefile +++ b/Makefile @@ -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/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -104,6 +104,13 @@ build-debug: 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: @rm -rf dist/ release/ diff --git a/README.md b/README.md index 0254a3c..863ed17 100644 --- a/README.md +++ b/README.md @@ -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 debug build, and keeping its `dist/` on failure (see [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 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/` diff --git a/TODO.md b/TODO.md index 3871d10..5959a36 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,16 @@ but the review is broader than any of them. # 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 suites do to the extension ([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close` diff --git a/build.js b/build.js index 2fc632a..0cf68e4 100644 --- a/build.js +++ b/build.js @@ -588,15 +588,10 @@ async function build() { copyIcons(distDir); } - // copy manifests - copyEmitted( - path.join(__dirname, "manifest", "chrome.json"), - path.join(DIST_CHROME, "manifest.json"), - ); - copyEmitted( - path.join(__dirname, "manifest", "firefox.json"), - path.join(DIST_FIREFOX, "manifest.json"), - ); + // copy manifests, the same files copyIcons() read + for (const [distDir, manifestPath] of MANIFEST_SOURCES) { + copyEmitted(manifestPath, path.join(distDir, "manifest.json")); + } assertForbiddenTableCovered(forbiddenRecord); diff --git a/script/lib/icons.js b/script/lib/icons.js new file mode 100644 index 0000000..77b71e5 --- /dev/null +++ b/script/lib/icons.js @@ -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 }; diff --git a/tests/icons.test.js b/tests/icons.test.js new file mode 100644 index 0000000..c8c72dc --- /dev/null +++ b/tests/icons.test.js @@ -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); + }, + ); +});