// 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 };