mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
Port to TypeScript with dual Node + Cloudflare Workers targets
Runtime is pure Web Standard APIs (fetch, Request, Response, Uint8Array,
CompressionStream, crypto.subtle, btoa) — zero runtime dependencies.
Core (src/core/, runs identically on Node 18+ and Workers):
- atlas.ts auto-generated 9x15 JBM glyph atlas, base64-inlined (17KB)
- png.ts minimal grayscale PNG encoder via CompressionStream
- render.ts text → packed PNG, soft-wraps at 100 cols, ≤1568px tall
- transform.ts request body rewriter (system + tool docs → image blocks)
- proxy.ts fetch-handler that transforms then forwards to Anthropic
- types.ts Anthropic Messages API types we touch
Adapters:
- src/node.ts node:http server + CLI flag parsing
- src/worker.ts export default { fetch } for wrangler dev/deploy
Build tooling:
- scripts/gen-atlas.ts @napi-rs/canvas → atlas.ts (build-time only)
- scripts/build.mjs esbuild Node bundle; wrangler handles Worker
- tsconfig.json strict, ES2022, Workers types
Tests (vitest): 8 passing — PNG signature, base64 round-trip, single +
multi-image renders, transform no-op + compress paths, billing-line strip,
tool fold + stub.
E2E smoke: 16K char system → 2 PNGs (36KB) → mock upstream, 34ms.
Next: replace docs, then verify byte-output parity against legacy/python.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
// Build the Node bundle. The Worker target is built by wrangler directly
|
||||
// from src/worker.ts (no separate build step needed).
|
||||
import { build } from 'esbuild';
|
||||
import { mkdir, copyFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const OUT = 'dist';
|
||||
if (!existsSync(OUT)) await mkdir(OUT, { recursive: true });
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/node.ts'],
|
||||
outfile: 'dist/node.js',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'esm',
|
||||
sourcemap: true,
|
||||
// Atlas is inlined as a base64 string in src/core/atlas.ts, so no external assets.
|
||||
external: [],
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
});
|
||||
|
||||
console.log('✓ built dist/node.js');
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Build-time glyph atlas generator.
|
||||
*
|
||||
* Reads assets/JetBrainsMono-Regular.ttf, rasterizes the printable ASCII
|
||||
* range (0x20..0x7E) into a fixed-cell grayscale atlas, and emits
|
||||
* src/core/atlas.ts with the pixel data inlined as a base64 string.
|
||||
*
|
||||
* Runs only at build time. The generated atlas.ts has zero runtime deps —
|
||||
* it works identically in Node and Cloudflare Workers.
|
||||
*/
|
||||
|
||||
import { GlobalFonts, createCanvas } from '@napi-rs/canvas';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const TTF_PATH = resolve(ROOT, 'assets/JetBrainsMono-Regular.ttf');
|
||||
const OUT_PATH = resolve(ROOT, 'src/core/atlas.ts');
|
||||
|
||||
const FONT_FAMILY = 'JBM';
|
||||
const FONT_PX = Number(process.env.FONT_PX ?? 15);
|
||||
const FIRST = 0x20; // space
|
||||
const LAST = 0x7e; // tilde
|
||||
const NUM_GLYPHS = LAST - FIRST + 1;
|
||||
|
||||
// Register the bundled TTF so node-canvas can use it by family name.
|
||||
const ttf = readFileSync(TTF_PATH);
|
||||
GlobalFonts.register(ttf, FONT_FAMILY);
|
||||
|
||||
// 1. Measure cell dimensions using a representative glyph (capital M is widest
|
||||
// in JBM; for monospace they're all the same width, but we measure to be safe).
|
||||
const probe = createCanvas(128, 128);
|
||||
const pctx = probe.getContext('2d');
|
||||
pctx.font = `${FONT_PX}px ${FONT_FAMILY}`;
|
||||
pctx.textBaseline = 'alphabetic';
|
||||
const m = pctx.measureText('M');
|
||||
const cellW = Math.ceil(m.width);
|
||||
const ascent = Math.ceil(m.actualBoundingBoxAscent);
|
||||
// Use the deepest descender ('g' or 'p') to size cell height — '|' isn't enough.
|
||||
const dm = pctx.measureText('gjpqy|');
|
||||
const descent = Math.ceil(dm.actualBoundingBoxDescent);
|
||||
const cellH = ascent + descent;
|
||||
|
||||
console.log(`[gen-atlas] font=${FONT_FAMILY} px=${FONT_PX} cell=${cellW}x${cellH} (asc=${ascent} desc=${descent})`);
|
||||
|
||||
// 2. Render each glyph into a single-channel grayscale buffer.
|
||||
const pixels = new Uint8Array(NUM_GLYPHS * cellW * cellH);
|
||||
const cell = createCanvas(cellW, cellH);
|
||||
const ctx = cell.getContext('2d');
|
||||
ctx.font = `${FONT_PX}px ${FONT_FAMILY}`;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
for (let code = FIRST; code <= LAST; code++) {
|
||||
// Black background, white text → R channel == coverage.
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, cellW, cellH);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillText(String.fromCharCode(code), 0, ascent);
|
||||
|
||||
const img = ctx.getImageData(0, 0, cellW, cellH);
|
||||
const dst = (code - FIRST) * cellW * cellH;
|
||||
for (let i = 0; i < cellW * cellH; i++) {
|
||||
// R channel only (RGBA stride 4)
|
||||
pixels[dst + i] = img.data[i * 4]!;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Encode as base64 and write the generated module.
|
||||
const b64 = Buffer.from(pixels).toString('base64');
|
||||
const banner = `// AUTO-GENERATED by scripts/gen-atlas.ts — DO NOT EDIT.
|
||||
// Regenerate with: npm run build:atlas
|
||||
// Source font: assets/JetBrainsMono-Regular.ttf @ ${FONT_PX}px
|
||||
`;
|
||||
|
||||
const body = `
|
||||
export const ATLAS_CELL_W = ${cellW};
|
||||
export const ATLAS_CELL_H = ${cellH};
|
||||
export const ATLAS_ASCENT = ${ascent};
|
||||
export const ATLAS_DESCENT = ${descent};
|
||||
export const ATLAS_FIRST = 0x${FIRST.toString(16)};
|
||||
export const ATLAS_LAST = 0x${LAST.toString(16)};
|
||||
export const ATLAS_NUM_GLYPHS = ${NUM_GLYPHS};
|
||||
export const ATLAS_FONT_PX = ${FONT_PX};
|
||||
|
||||
/** Base64-encoded glyph pixels. Layout: glyph-major, row-major within each glyph.
|
||||
* For char code C in [FIRST..LAST]:
|
||||
* offset = (C - FIRST) * CELL_W * CELL_H
|
||||
* pixel(x, y) = bytes[offset + y * CELL_W + x] // 0..255 coverage
|
||||
*/
|
||||
const ATLAS_B64 = ${JSON.stringify(b64)};
|
||||
|
||||
function decodeBase64(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decoded glyph pixels, ready to blit. */
|
||||
export const ATLAS_PIXELS: Uint8Array = /* @__PURE__ */ decodeBase64(ATLAS_B64);
|
||||
`;
|
||||
|
||||
writeFileSync(OUT_PATH, banner + body);
|
||||
console.log(`[gen-atlas] wrote ${OUT_PATH} (${pixels.length} bytes raw, ${b64.length} b64 chars)`);
|
||||
Reference in New Issue
Block a user