/** * Hand-rolled PNG encoder for the SRS Worker. * * Every major email client — Gmail, Outlook, Apple Mail — refuses to render * SVG, inline or remote, so the daily digest cannot reuse /chart/heatmap.svg; * it needs raster bytes. The Worker ships with no runtime dependencies by * design (SVG, HMAC and GraphQL are all hand-rolled here), so this file * encodes PNG itself instead of pulling in a codec. * * The format reduces to four things the runtime already provides: * - an 8-byte signature plus chunks (IHDR, IDAT, IEND), each one * length + type + payload + CRC32, every integer big-endian; * - one filter byte per scanline, always 0 here: these images are flat * rectangles, so a predictor would buy nothing; * - zlib-wrapped deflate for IDAT, which is exactly what * `new CompressionStream("deflate")` emits ("deflate-raw" would not); * - CRC32, a 256-entry table built lazily on the first chunk. * * Colour type 2 (truecolour, 8-bit, no alpha): these charts are opaque cards, * so an alpha channel would cost a third more bytes for nothing. That does mean * transparency is unavailable — a caller wanting rounded corners paints the * colour that sits behind the image first, then the rounded card on top. */ // ── crc32 ──────────────────────────────────────────────────────── // One table for every chunk of every image; built on first use. let crcTable: Uint32Array | undefined; function crc32(bytes: Uint8Array): number { let table = crcTable; if (!table) { table = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c; } crcTable = table; } let crc = 0xffffffff; for (const byte of bytes) crc = table[(crc ^ byte) & 0xff]! ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } // ── chunks ─────────────────────────────────────────────────────── const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** length, 4-char type, payload, then CRC32 over the type and payload. */ function chunk(type: string, data: Uint8Array): Uint8Array { const out = new Uint8Array(data.length + 12); const view = new DataView(out.buffer); view.setUint32(0, data.length); for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); out.set(data, 8); view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8))); return out; } // ── 5x7 bitmap font ────────────────────────────────────────────── const GLYPH_W = 5; const GLYPH_H = 7; /** * Seven row bitmasks per glyph, five low bits each, MSB (0b10000) leftmost. * Uppercase only: chart labels are short and mechanical, and a lowercase set * would double the table for no gain. Unlisted characters render blank. */ const GLYPHS: Record = { A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110], C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110], D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110], E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111], F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000], G: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110], H: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], I: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111], J: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100], K: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001], L: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111], M: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001], N: [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001], O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], P: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000], Q: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10011, 0b01101], R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], S: [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110], T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100], U: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], V: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100], W: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001], X: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001], Y: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100], Z: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111], "0": [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], "1": [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], "2": [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111], "3": [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110], "4": [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], "5": [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], "6": [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], "7": [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], "8": [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], "9": [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], " ": [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], "-": [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000], "/": [0b00001, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b10000], ":": [0b00000, 0b00100, 0b00100, 0b00000, 0b00100, 0b00100, 0b00000], }; /** * Device pixels `content` occupies at `scale`, inter-glyph gaps included. * Exported because callers need it to size a canvas before constructing one. */ export function textWidth(content: string, scale = 1): number { return content.length === 0 ? 0 : (content.length * (GLYPH_W + 1) - 1) * scale; } // ── canvas ─────────────────────────────────────────────────────── /** "#rrggbb" to its three channel bytes; called once per draw, not per pixel. */ function rgb(color: string): [number, number, number] { const packed = Number.parseInt(color.slice(1), 16); return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff]; } /** Fixed-size RGB canvas: filled rectangles plus 5x7 bitmap text. */ export class Canvas { readonly width: number; readonly height: number; /** Row-major RGB triples, unpadded; encode() interleaves the filter bytes. */ private readonly pixels: Uint8Array; constructor(width: number, height: number, background: string) { this.width = width; this.height = height; this.pixels = new Uint8Array(width * height * 3); const [r, g, b] = rgb(background); for (let i = 0; i < this.pixels.length; i += 3) { this.pixels[i] = r; this.pixels[i + 1] = g; this.pixels[i + 2] = b; } } /** * Filled rectangle, clipped to the canvas. `radius` rounds all four corners * by dropping the pixels whose centre falls outside the corner circle — * enough for the small cell roundings and the card these charts draw. */ rect(x: number, y: number, w: number, h: number, color: string, radius = 0): void { const [r, g, b] = rgb(color); // Device pixels only: a fractional origin would index the buffer between // bytes and silently drop the whole rectangle. const left = Math.round(x); const top = Math.round(y); const rw = Math.round(w); const rh = Math.round(h); const rad = Math.min(radius, rw / 2, rh / 2); const y1 = Math.min(this.height, top + rh); const x1 = Math.min(this.width, left + rw); for (let py = Math.max(0, top); py < y1; py++) { // How far this row lies past the nearer corner centre; 0 in between. const cy = py + 0.5; const dy = cy < top + rad ? top + rad - cy : cy > top + rh - rad ? cy - (top + rh - rad) : 0; const rowBase = py * this.width * 3; for (let px = Math.max(0, left); px < x1; px++) { const cx = px + 0.5; const dx = cx < left + rad ? left + rad - cx : cx > left + rw - rad ? cx - (left + rw - rad) : 0; if (dx * dx + dy * dy > rad * rad) continue; const at = rowBase + px * 3; this.pixels[at] = r; this.pixels[at + 1] = g; this.pixels[at + 2] = b; } } } /** * Bitmap text from a top-left origin (not a baseline), `scale` device pixels * per glyph pixel, 1 glyph pixel of advance between characters. Input is * uppercased; unknown characters advance without drawing. */ text(x: number, y: number, content: string, color: string, scale = 1): void { const [r, g, b] = rgb(color); const left = Math.round(x); const top = Math.round(y); const upper = content.toUpperCase(); for (let i = 0; i < upper.length; i++) { const glyph = GLYPHS[upper[i]!]; if (!glyph) continue; const originX = left + i * (GLYPH_W + 1) * scale; for (let gy = 0; gy < GLYPH_H; gy++) { const bits = glyph[gy]!; if (bits === 0) continue; for (let gx = 0; gx < GLYPH_W; gx++) { if ((bits & (1 << (GLYPH_W - 1 - gx))) === 0) continue; // Every set glyph pixel is a scale x scale block of device pixels. const blockX = originX + gx * scale; const blockY = top + gy * scale; const y1 = Math.min(this.height, blockY + scale); const x1 = Math.min(this.width, blockX + scale); for (let py = Math.max(0, blockY); py < y1; py++) { const rowBase = py * this.width * 3; for (let px = Math.max(0, blockX); px < x1; px++) { const at = rowBase + px * 3; this.pixels[at] = r; this.pixels[at + 1] = g; this.pixels[at + 2] = b; } } } } } } /** PNG bytes: 8-bit truecolour, one IDAT, zlib via CompressionStream. */ async encode(): Promise { const stride = this.width * 3; const raw = new Uint8Array((stride + 1) * this.height); for (let y = 0; y < this.height; y++) { // Leading 0 of each scanline slot is the "no filter" byte. raw.set(this.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); } const deflated = new Response(raw).body!.pipeThrough(new CompressionStream("deflate")); const idat = new Uint8Array(await new Response(deflated).arrayBuffer()); const ihdr = new Uint8Array(13); const header = new DataView(ihdr.buffer); header.setUint32(0, this.width); header.setUint32(4, this.height); ihdr[8] = 8; // bit depth ihdr[9] = 2; // colour type 2: truecolour RGB // Bytes 10-12 stay 0: deflate, adaptive filtering, no interlace. const chunks = [chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))]; let total = SIGNATURE.length; for (const c of chunks) total += c.length; const png = new Uint8Array(total); png.set(SIGNATURE); let at = SIGNATURE.length; for (const c of chunks) { png.set(c, at); at += c.length; } return png; } }