mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 21:27:48 +00:00
feat(api): add SRS Cloudflare Worker with email digest, charts, and D1 integration
This commit is contained in:
1 parent
f551878961
commit
a3431da300
21 files changed
+3166
No files matched your search
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user