mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
/**
|
|
* 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<string, number[]> = {
|
|
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<Uint8Array> {
|
|
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;
|
|
}
|
|
}
|