mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
41f2ed1d39
* fix(render): glyph surgery so Spleen 5x8 K no longer reads as H The stock Spleen K was H with one crossbar pixel removed (Hamming 1), the worst confusable pair in the atlas. Because the API downscales images and the model infers rather than scans, that difference vanishes and K is silently misread as H - with no signal of uncertainty. gen-atlas.ts now repaints K with a diagonal-legged bitmap: Hamming 8 from H, and >=6 from every other ASCII glyph. Scoped to the narrow Spleen 5x8 primary cell, so the JetBrains-Mono and CJK-fallback atlases are untouched. Zero token-cost change - the glyph occupies the same 5x8 box. Guarded by a regression test asserting no alphanumeric pair sits below Hamming 2. Reproduce with: npx tsx eval/glyph-matrix/demo-glyph.mts Closes the "optional glyph surgery" item in the 2026-07-01 legibility audit.
576 lines
23 KiB
TypeScript
576 lines
23 KiB
TypeScript
/**
|
||
* Build-time glyph atlas generator — Unicode-capable hybrid code-font atlas.
|
||
*
|
||
* Default atlas (2026-05): Spleen 5×8 for printable ASCII/code glyphs,
|
||
* Unifont 16.0.04 at 8px for Unicode fallback (CJK, arrows, symbols,
|
||
* math, Hangul, etc.). The runtime renderer still sees one sparse atlas:
|
||
* codepoint → bit offset + wide flag. There is no runtime font dependency.
|
||
*
|
||
* Why hybrid instead of replacing Unifont outright:
|
||
* - Spleen 5×8 is a real bitmap/code font and is materially denser than
|
||
* historical Unifont 10px atlas: 5×8 cells vs 5×11, ~38% more rows
|
||
* per 1932px-tall image.
|
||
* - Spleen intentionally targets small code/terminal glyphs, but does not
|
||
* cover CJK/symbol blocks. Unifont remains the broad fallback so existing
|
||
* dropped-glyph behavior does not regress for non-ASCII text.
|
||
* - The generator bakes both into one 1-bit atlas, preserving the Workers-safe
|
||
* zero-runtime-dependency contract.
|
||
*
|
||
* Profiles (selected via ATLAS_PROFILE env, default 'full-bmp'):
|
||
* - 'full-bmp' (~35k codepoints): practical Unicode blocks + Hangul.
|
||
* - 'practical' (~24k codepoints): drops Hangul Syllables for smaller bundles.
|
||
*/
|
||
|
||
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 PRIMARY_FONT_PATH = resolve(
|
||
ROOT,
|
||
process.env.ATLAS_PRIMARY_FONT ?? 'assets/Spleen-5x8.otb',
|
||
);
|
||
const FALLBACK_FONT_PATH = resolve(
|
||
ROOT,
|
||
process.env.ATLAS_FALLBACK_FONT ?? 'assets/Unifont-16.0.04.otf',
|
||
);
|
||
// Eval sweeps may write outside src/core without touching production atlases.
|
||
const OUT_PATH = resolve(ROOT, process.env.ATLAS_OUT ?? 'src/core/atlas.ts');
|
||
|
||
/** When ATLAS_GRAY=1, write a separate grayscale atlas to atlas-gray.ts instead
|
||
* of the production 1-bit atlas. The production atlas is never touched. */
|
||
const GRAY_MODE = process.env['ATLAS_GRAY'] === '1';
|
||
const OUT_PATH_GRAY = resolve(
|
||
ROOT,
|
||
process.env.ATLAS_OUT_GRAY ?? 'src/core/atlas-gray.ts',
|
||
);
|
||
|
||
const PRIMARY_FONT_FAMILY = process.env.ATLAS_PRIMARY_FAMILY ?? 'Spleen';
|
||
const FALLBACK_FONT_FAMILY = process.env.ATLAS_FALLBACK_FAMILY ?? 'Unifont';
|
||
const PRIMARY_FONT_PX = Number(process.env.ATLAS_PRIMARY_PX ?? 8);
|
||
const FALLBACK_FONT_PX = Number(process.env.ATLAS_FALLBACK_PX ?? PRIMARY_FONT_PX);
|
||
const FONT_FAMILY_LABEL =
|
||
process.env.ATLAS_LABEL ??
|
||
`${PRIMARY_FONT_FAMILY} ${PRIMARY_FONT_PX}px + ${FALLBACK_FONT_FAMILY} ${FALLBACK_FONT_PX}px fallback`;
|
||
const PRIMARY_FONT_LABEL = process.env.ATLAS_PRIMARY_FONT ?? 'assets/Spleen-5x8.otb';
|
||
const FALLBACK_FONT_LABEL = process.env.ATLAS_FALLBACK_FONT ?? 'assets/Unifont-16.0.04.otf';
|
||
const PROFILE = (process.env.ATLAS_PROFILE ?? 'full-bmp') as
|
||
| 'practical'
|
||
| 'full-bmp'
|
||
| 'ascii';
|
||
// Eval-only: allow non-5x8 cells when generating alternate fonts/sizes.
|
||
const ALLOW_ANY_CELL = process.env.ATLAS_ALLOW_ANY_CELL === '1';
|
||
|
||
/** Codepoint blocks included in each profile. */
|
||
const PRACTICAL_RANGES: ReadonlyArray<readonly [number, number, string]> = [
|
||
[0x0020, 0x007e, 'ASCII printable'],
|
||
[0x00a0, 0x024f, 'Latin-1 Supp + Latin Extended-A + Latin Extended-B'],
|
||
[0x0370, 0x03ff, 'Greek and Coptic'],
|
||
[0x0400, 0x04ff, 'Cyrillic'],
|
||
[0x0590, 0x05ff, 'Hebrew'],
|
||
[0x0600, 0x06ff, 'Arabic'],
|
||
[0x2000, 0x206f, 'General Punctuation'],
|
||
[0x2100, 0x214f, 'Letterlike Symbols'],
|
||
[0x2190, 0x21ff, 'Arrows'],
|
||
[0x2200, 0x22ff, 'Mathematical Operators'],
|
||
[0x2300, 0x23ff, 'Miscellaneous Technical'],
|
||
[0x2460, 0x24ff, 'Enclosed Alphanumerics'],
|
||
[0x2500, 0x257f, 'Box Drawing'],
|
||
[0x2580, 0x259f, 'Block Elements'],
|
||
[0x25a0, 0x25ff, 'Geometric Shapes'],
|
||
[0x2600, 0x26ff, 'Miscellaneous Symbols'],
|
||
[0x2700, 0x27bf, 'Dingbats'],
|
||
[0x3000, 0x303f, 'CJK Symbols and Punctuation'],
|
||
[0x3040, 0x309f, 'Hiragana'],
|
||
[0x30a0, 0x30ff, 'Katakana'],
|
||
[0xff00, 0xffef, 'Halfwidth and Fullwidth Forms'],
|
||
[0x4e00, 0x9fff, 'CJK Unified Ideographs'],
|
||
];
|
||
|
||
const HANGUL: ReadonlyArray<readonly [number, number, string]> = [
|
||
[0xac00, 0xd7af, 'Hangul Syllables'],
|
||
];
|
||
|
||
const ASCII_RANGES: ReadonlyArray<readonly [number, number, string]> = [
|
||
[0x0020, 0x007e, 'ASCII printable'],
|
||
[0x00a0, 0x00ff, 'Latin-1 Supplement'],
|
||
[0x2190, 0x21ff, 'Arrows'],
|
||
[0x2200, 0x22ff, 'Mathematical Operators'],
|
||
[0x2500, 0x259f, 'Box Drawing + Block Elements'],
|
||
];
|
||
|
||
const RANGES =
|
||
PROFILE === 'full-bmp'
|
||
? [...PRACTICAL_RANGES, ...HANGUL]
|
||
: PROFILE === 'ascii'
|
||
? ASCII_RANGES
|
||
: PRACTICAL_RANGES;
|
||
|
||
// --- Register fonts --------------------------------------------------------
|
||
GlobalFonts.register(readFileSync(PRIMARY_FONT_PATH), PRIMARY_FONT_FAMILY);
|
||
GlobalFonts.register(readFileSync(FALLBACK_FONT_PATH), FALLBACK_FONT_FAMILY);
|
||
|
||
// Spleen 5x8 defines the global Latin cell. Unifont fallback at 8px is
|
||
// narrower (4px Latin / 8px CJK) and is painted into the same 5/10px cells,
|
||
// leaving a little right-side blank space rather than changing renderer math.
|
||
const probe = createCanvas(64, 64);
|
||
const pctx = probe.getContext('2d');
|
||
pctx.textBaseline = 'alphabetic';
|
||
pctx.font = `${PRIMARY_FONT_PX}px ${PRIMARY_FONT_FAMILY}`;
|
||
const primaryLatin = pctx.measureText('M');
|
||
let maxAscent = 0;
|
||
let maxDescent = 0;
|
||
for (const ch of ['M', 'g', 'p', 'y', 'j', '0', 'O', 'l', 'I', '{', '}', '[', ']']) {
|
||
const m = pctx.measureText(ch);
|
||
if (Number.isFinite(m.actualBoundingBoxAscent) && m.actualBoundingBoxAscent > maxAscent) {
|
||
maxAscent = m.actualBoundingBoxAscent;
|
||
}
|
||
if (Number.isFinite(m.actualBoundingBoxDescent) && m.actualBoundingBoxDescent > maxDescent) {
|
||
maxDescent = m.actualBoundingBoxDescent;
|
||
}
|
||
}
|
||
// Also verify fallback fits in the same height budget at its own baseline.
|
||
pctx.font = `${FALLBACK_FONT_PX}px ${FALLBACK_FONT_FAMILY}`;
|
||
for (const ch of ['M', 'g', 'p', 'y', 'j', '中', '漢', '國', '⌊', '∫', '日', 'カ', '한']) {
|
||
const m = pctx.measureText(ch);
|
||
if (Number.isFinite(m.actualBoundingBoxAscent) && m.actualBoundingBoxAscent > maxAscent) {
|
||
maxAscent = m.actualBoundingBoxAscent;
|
||
}
|
||
if (Number.isFinite(m.actualBoundingBoxDescent) && m.actualBoundingBoxDescent > maxDescent) {
|
||
maxDescent = m.actualBoundingBoxDescent;
|
||
}
|
||
}
|
||
|
||
const cellW = Math.ceil(primaryLatin.width); // 5 at Spleen 8px
|
||
const ascent = Math.ceil(maxAscent); // 7 for Spleen/Unifont 8px
|
||
const descent = Math.ceil(maxDescent); // 1 for Spleen/Unifont 8px
|
||
const cellH = ascent + descent; // 8
|
||
|
||
if (!ALLOW_ANY_CELL && (cellW !== 5 || cellH !== 8)) {
|
||
throw new Error(
|
||
`[gen-atlas] Spleen 5x8 invariant drifted: got cell=${cellW}×${cellH} ` +
|
||
`(asc=${ascent} desc=${descent}). Refusing to silently change density.`,
|
||
);
|
||
}
|
||
if (ALLOW_ANY_CELL) {
|
||
console.log(
|
||
`[gen-atlas] ATLAS_ALLOW_ANY_CELL=1: accepting cell=${cellW}×${cellH} ` +
|
||
`(asc=${ascent} desc=${descent})`,
|
||
);
|
||
}
|
||
|
||
// Probe fallback advance against fallback's own Latin baseline. We classify
|
||
// fallback glyphs as one or two visual cells, then paint them into Spleen's
|
||
// 5px or 10px cell width. This keeps CJK wrapping at 2 cells even though
|
||
// Unifont 8px ink itself is 8px wide.
|
||
pctx.font = `${FALLBACK_FONT_PX}px ${FALLBACK_FONT_FAMILY}`;
|
||
const fallbackLatinW = pctx.measureText('M').width;
|
||
if (!Number.isFinite(fallbackLatinW) || fallbackLatinW <= 0) {
|
||
throw new Error('[gen-atlas] could not measure fallback Unifont Latin width');
|
||
}
|
||
|
||
console.log(
|
||
`[gen-atlas] font=${FONT_FAMILY_LABEL} profile=${PROFILE} ` +
|
||
`cell=${cellW}×${cellH} (asc=${ascent} desc=${descent}, wide=${2 * cellW}×${cellH})`,
|
||
);
|
||
|
||
interface Found {
|
||
cp: number;
|
||
wide: boolean;
|
||
source: 'primary' | 'fallback';
|
||
}
|
||
|
||
function sourceForCodepoint(cp: number): 'primary' | 'fallback' {
|
||
// Code-font-first means the exact ASCII/code glyphs that dominate Claude
|
||
// Code prompts use Spleen. Unicode punctuation/symbols/CJK keep Unifont.
|
||
// ATLAS_PRIMARY_ONLY=1 forces a single face (eval mono fonts).
|
||
if (process.env.ATLAS_PRIMARY_ONLY === '1') return 'primary';
|
||
if (cp >= 0x20 && cp <= 0x7e) return 'primary';
|
||
return 'fallback';
|
||
}
|
||
|
||
function classifyFallbackWidth(cp: number): boolean | null {
|
||
const ch = String.fromCodePoint(cp);
|
||
const w = pctx.measureText(ch).width;
|
||
if (!Number.isFinite(w) || w <= 0) return null;
|
||
const ratio = w / fallbackLatinW;
|
||
if (Math.abs(ratio - 1) < 0.05) return false;
|
||
if (Math.abs(ratio - 2) < 0.05) return true;
|
||
throw new Error(
|
||
`[gen-atlas] fallback codepoint U+${cp.toString(16).toUpperCase()} has advance ` +
|
||
`${w}px (Unifont Latin=${fallbackLatinW}px, ratio=${ratio.toFixed(3)}; expected 1× or 2×).`,
|
||
);
|
||
}
|
||
|
||
const found: Found[] = [];
|
||
for (const [lo, hi, label] of RANGES) {
|
||
let kept = 0;
|
||
let primary = 0;
|
||
let fallback = 0;
|
||
for (let cp = lo; cp <= hi; cp++) {
|
||
const source = sourceForCodepoint(cp);
|
||
if (source === 'primary') {
|
||
found.push({ cp, wide: false, source });
|
||
primary++;
|
||
kept++;
|
||
continue;
|
||
}
|
||
const wide = classifyFallbackWidth(cp);
|
||
if (wide == null) continue;
|
||
found.push({ cp, wide, source });
|
||
fallback++;
|
||
kept++;
|
||
}
|
||
console.log(
|
||
`[gen-atlas] ${label.padEnd(48)} ` +
|
||
`U+${lo.toString(16).padStart(4, '0').toUpperCase()}..` +
|
||
`U+${hi.toString(16).padStart(4, '0').toUpperCase()} ` +
|
||
`kept ${kept}/${hi - lo + 1} (primary=${primary}, fallback=${fallback})`,
|
||
);
|
||
}
|
||
|
||
found.sort((a, b) => a.cp - b.cp);
|
||
const wideCount = found.filter((f) => f.wide).length;
|
||
const primaryCount = found.filter((f) => f.source === 'primary').length;
|
||
console.log(`[gen-atlas] total glyphs: ${found.length} (${wideCount} wide, ${primaryCount} primary)`);
|
||
|
||
// --- Rasterize glyphs ------------------------------------------------------
|
||
const contexts = {
|
||
primary: {
|
||
narrow: createCanvas(cellW, cellH).getContext('2d'),
|
||
wide: createCanvas(2 * cellW, cellH).getContext('2d'),
|
||
font: `${PRIMARY_FONT_PX}px ${PRIMARY_FONT_FAMILY}`,
|
||
},
|
||
fallback: {
|
||
narrow: createCanvas(cellW, cellH).getContext('2d'),
|
||
wide: createCanvas(2 * cellW, cellH).getContext('2d'),
|
||
font: `${FALLBACK_FONT_PX}px ${FALLBACK_FONT_FAMILY}`,
|
||
},
|
||
} as const;
|
||
for (const src of [contexts.primary, contexts.fallback]) {
|
||
for (const ctx of [src.narrow, src.wide]) {
|
||
ctx.font = src.font;
|
||
ctx.textBaseline = 'alphabetic';
|
||
}
|
||
}
|
||
|
||
// --- Glyph surgery ---------------------------------------------------------
|
||
// A few stock Spleen 5×8 cells are pathologically confusable with another
|
||
// glyph at the atlas's pixel budget (see docs/LEGIBILITY-AUDIT-2026-07-01.md
|
||
// §2). We repaint those cells with a hand-tuned bitmap. Keyed by codepoint;
|
||
// each value is exactly `cellH` rows of `cellW` chars ('#' = ink). Applied
|
||
// ONLY to the narrow primary Spleen 5×8 cell, so the JetBrains-Mono (6×11)
|
||
// and CJK-fallback atlases are never touched.
|
||
//
|
||
// U+004B 'K': stock Spleen K is 'H' with one crossbar pixel removed (Hamming
|
||
// distance 1 — the worst confusable pair in the atlas). This diagonal-legged
|
||
// K sits at Hamming ≥6 from every other ASCII glyph.
|
||
const GLYPH_OVERRIDES: Record<number, readonly string[]> = {
|
||
0x4b: ['.....', '#..#.', '#.#..', '##...', '##...', '#.#..', '#..#.', '.....'],
|
||
};
|
||
const SURGERY_OK = cellW === 5 && cellH === 8;
|
||
function overrideCoverage(cp: number): Uint8Array | null {
|
||
const rows = GLYPH_OVERRIDES[cp];
|
||
if (!rows) return null;
|
||
if (rows.length !== cellH || rows.some((r) => r.length !== cellW)) {
|
||
throw new Error(
|
||
`[gen-atlas] glyph override U+${cp.toString(16).toUpperCase()} must be ` +
|
||
`${cellW}×${cellH}, got ${rows[0]?.length}×${rows.length}.`,
|
||
);
|
||
}
|
||
const cov = new Uint8Array(cellW * cellH);
|
||
for (let y = 0; y < cellH; y++)
|
||
for (let x = 0; x < cellW; x++) cov[y * cellW + x] = rows[y]![x] === '#' ? 255 : 0;
|
||
return cov;
|
||
}
|
||
|
||
const codepoints = new Uint32Array(found.length);
|
||
const offsets = new Uint32Array(found.length);
|
||
const wideFlags = new Uint8Array(found.length);
|
||
const cellBitSlices: Uint8Array[] = [];
|
||
let totalBits = 0;
|
||
let surgeryApplied = 0;
|
||
|
||
// Gray mode: collect raw coverage bytes (0-255) per pixel, 1 byte per pixel.
|
||
// Used only when ATLAS_GRAY=1; the 1-bit path is unchanged when GRAY_MODE is false.
|
||
const graySlices: Uint8Array[] = [];
|
||
let grayTotalBytes = 0;
|
||
|
||
for (let i = 0; i < found.length; i++) {
|
||
const { cp, wide, source } = found[i]!;
|
||
codepoints[i] = cp;
|
||
wideFlags[i] = wide ? 1 : 0;
|
||
|
||
const w = wide ? 2 * cellW : cellW;
|
||
const ctx = wide ? contexts[source].wide : contexts[source].narrow;
|
||
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(0, 0, w, cellH);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillText(String.fromCodePoint(cp), 0, ascent);
|
||
|
||
const img = ctx.getImageData(0, 0, w, cellH);
|
||
|
||
// Glyph surgery: swap in the hand-tuned bitmap for confusable narrow Spleen
|
||
// cells. `ov[p]` (0/255) stands in for the canvas R-channel byte everywhere.
|
||
const ov = SURGERY_OK && source === 'primary' && !wide ? overrideCoverage(cp) : null;
|
||
if (ov) surgeryApplied++;
|
||
|
||
if (GRAY_MODE) {
|
||
// Gray mode: record the raw R-channel coverage byte (canvas is white-on-black).
|
||
offsets[i] = grayTotalBytes; // BYTE offset (not bit offset)
|
||
const coverage = new Uint8Array(w * cellH);
|
||
for (let p = 0; p < coverage.length; p++) coverage[p] = ov ? ov[p]! : img.data[p * 4]!;
|
||
graySlices.push(coverage);
|
||
grayTotalBytes += coverage.length;
|
||
} else {
|
||
// 1-bit mode (default / production path): threshold and bit-pack.
|
||
offsets[i] = totalBits;
|
||
const bits = new Uint8Array(w * cellH);
|
||
for (let p = 0; p < bits.length; p++) bits[p] = (ov ? ov[p]! : img.data[p * 4]!) >= 128 ? 1 : 0;
|
||
cellBitSlices.push(bits);
|
||
totalBits += bits.length;
|
||
}
|
||
}
|
||
|
||
if (SURGERY_OK)
|
||
console.log(
|
||
`[gen-atlas] glyph surgery: ${surgeryApplied}/${Object.keys(GLYPH_OVERRIDES).length} ` +
|
||
`override(s) applied (Spleen 5×8 narrow cells).`,
|
||
);
|
||
|
||
function bytesB64(bytes: Uint8Array): string {
|
||
return Buffer.from(bytes).toString('base64');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Gray-mode output — completely separate file, never touches atlas.ts
|
||
// ---------------------------------------------------------------------------
|
||
if (GRAY_MODE) {
|
||
// Flatten all coverage slices into one Uint8Array.
|
||
const grayPixels = new Uint8Array(grayTotalBytes);
|
||
let off = 0;
|
||
for (const slice of graySlices) {
|
||
grayPixels.set(slice, off);
|
||
off += slice.length;
|
||
}
|
||
|
||
const codepointsB64 = bytesB64(new Uint8Array(codepoints.buffer));
|
||
const offsetsB64 = bytesB64(new Uint8Array(offsets.buffer));
|
||
const wideFlagsB64 = bytesB64(wideFlags);
|
||
const pixelsB64 = bytesB64(grayPixels);
|
||
|
||
console.log(
|
||
`[gen-atlas] GRAY MODE: coverage byte storage: ${grayTotalBytes} bytes ` +
|
||
`(${pixelsB64.length} b64 chars)`,
|
||
);
|
||
console.log(
|
||
`[gen-atlas] sizes (base64 chars): codepoints=${codepointsB64.length} ` +
|
||
`offsets=${offsetsB64.length} wide=${wideFlagsB64.length} pixels=${pixelsB64.length}`,
|
||
);
|
||
|
||
const grayBanner = `// AUTO-GENERATED by scripts/gen-atlas.ts (ATLAS_GRAY=1) — DO NOT EDIT.
|
||
// Regenerate with: ATLAS_GRAY=1 npx tsx scripts/gen-atlas.ts
|
||
// Grayscale companion atlas; imported when RenderStyle.aa is true.
|
||
// Source fonts: ${PRIMARY_FONT_LABEL} @ ${PRIMARY_FONT_PX}px primary; ${FALLBACK_FONT_LABEL} @ ${FALLBACK_FONT_PX}px fallback (profile: ${PROFILE})
|
||
// Glyphs: ${found.length} codepoints (${wideCount} wide, ${primaryCount} primary)
|
||
// Pixel format: 1 coverage byte per pixel (0-255), raw R-channel from anti-aliased canvas.
|
||
// ATLAS_GRAY_OFFSETS are BYTE offsets (not bit offsets like ATLAS_OFFSETS).
|
||
`;
|
||
|
||
const grayBody = `
|
||
/** Latin advance width in pixels. CJK glyphs advance ${2 * cellW}px (= 2 × this). */
|
||
export const ATLAS_GRAY_CELL_W = ${cellW};
|
||
/** Cell height in pixels. */
|
||
export const ATLAS_GRAY_CELL_H = ${cellH};
|
||
/** Distance from cell top to baseline. */
|
||
export const ATLAS_GRAY_ASCENT = ${ascent};
|
||
/** Distance from baseline to cell bottom. */
|
||
export const ATLAS_GRAY_DESCENT = ${descent};
|
||
|
||
// ---- base64 blobs (decoded once at module init) --------------------------
|
||
|
||
const GRAY_CODEPOINTS_B64 = ${JSON.stringify(codepointsB64)};
|
||
const GRAY_OFFSETS_B64 = ${JSON.stringify(offsetsB64)};
|
||
const GRAY_WIDE_FLAGS_B64 = ${JSON.stringify(wideFlagsB64)};
|
||
const GRAY_PIXELS_B64 = ${JSON.stringify(pixelsB64)};
|
||
|
||
/** Decode base64 → Uint8Array. Workers-safe (no Buffer / no node:zlib). */
|
||
function decodeB64(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;
|
||
}
|
||
|
||
function decodeU32(b64: string): Uint32Array {
|
||
const bytes = decodeB64(b64);
|
||
return new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
|
||
}
|
||
|
||
/** Sorted codepoint table. \`ATLAS_GRAY_CODEPOINTS[rank]\` is the codepoint at \`rank\`. */
|
||
export const ATLAS_GRAY_CODEPOINTS: Uint32Array = /* @__PURE__ */ decodeU32(GRAY_CODEPOINTS_B64);
|
||
|
||
/** BYTE offset into ATLAS_GRAY_PIXELS for the glyph at each rank.
|
||
* (Unlike the 1-bit atlas, this is a byte offset — 1 byte per pixel.) */
|
||
export const ATLAS_GRAY_OFFSETS: Uint32Array = /* @__PURE__ */ decodeU32(GRAY_OFFSETS_B64);
|
||
|
||
/** 1 if the glyph at this rank is double-wide (East Asian Wide), 0 otherwise. */
|
||
export const ATLAS_GRAY_WIDE_FLAGS: Uint8Array = /* @__PURE__ */ decodeB64(GRAY_WIDE_FLAGS_B64);
|
||
|
||
/** Coverage bytes (0-255), one byte per pixel. Runtime extraction:
|
||
* byteIdx = ATLAS_GRAY_OFFSETS[rank] + gy * srcW + gx
|
||
* coverage = ATLAS_GRAY_PIXELS[byteIdx]
|
||
* where srcW is ATLAS_GRAY_CELL_W (narrow) or 2*ATLAS_GRAY_CELL_W (wide). */
|
||
export const ATLAS_GRAY_PIXELS: Uint8Array = /* @__PURE__ */ decodeB64(GRAY_PIXELS_B64);
|
||
|
||
/** Number of glyphs in the gray atlas. */
|
||
export const ATLAS_GRAY_NUM_GLYPHS = ATLAS_GRAY_CODEPOINTS.length;
|
||
|
||
/** Binary-search the sparse codepoint table. Returns rank (≥0) or -1 if absent.
|
||
* Mirrors atlasRank() from atlas.ts but operates on ATLAS_GRAY_CODEPOINTS. */
|
||
export function atlasGrayRank(codepoint: number): number {
|
||
let lo = 0;
|
||
let hi = ATLAS_GRAY_CODEPOINTS.length - 1;
|
||
while (lo <= hi) {
|
||
const mid = (lo + hi) >> 1;
|
||
const v = ATLAS_GRAY_CODEPOINTS[mid]!;
|
||
if (v === codepoint) return mid;
|
||
if (v < codepoint) lo = mid + 1;
|
||
else hi = mid - 1;
|
||
}
|
||
return -1;
|
||
}
|
||
`;
|
||
|
||
writeFileSync(OUT_PATH_GRAY, grayBanner + grayBody);
|
||
console.log(
|
||
`[gen-atlas] wrote ${OUT_PATH_GRAY} ` +
|
||
`(${grayTotalBytes} coverage bytes, ${pixelsB64.length} b64 chars; ` +
|
||
`total file ~${Math.round((grayBanner.length + grayBody.length) / 1024)} KB)`,
|
||
);
|
||
// Do not write or touch src/core/atlas.ts — production path is unchanged.
|
||
process.exit(0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1-bit mode (default / production path) — unchanged below this point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const totalBytes = (totalBits + 7) >>> 3;
|
||
const pixels = new Uint8Array(totalBytes);
|
||
{
|
||
let bitOff = 0;
|
||
for (const bits of cellBitSlices) {
|
||
for (let p = 0; p < bits.length; p++) {
|
||
if (bits[p]!) {
|
||
const byteIdx = bitOff >>> 3;
|
||
const bitShift = 7 - (bitOff & 7);
|
||
pixels[byteIdx]! |= 1 << bitShift;
|
||
}
|
||
bitOff++;
|
||
}
|
||
}
|
||
}
|
||
console.log(
|
||
`[gen-atlas] bit-packed pixel storage: ${totalBits} bits → ${totalBytes} bytes ` +
|
||
`(was ${totalBits} bytes at 8-bit; ${(totalBits / totalBytes).toFixed(1)}× pre-deflate shrink)`,
|
||
);
|
||
|
||
const codepointsB64 = bytesB64(new Uint8Array(codepoints.buffer));
|
||
const offsetsB64 = bytesB64(new Uint8Array(offsets.buffer));
|
||
const wideFlagsB64 = bytesB64(wideFlags);
|
||
const pixelsB64 = bytesB64(pixels);
|
||
|
||
console.log(
|
||
`[gen-atlas] sizes (base64 chars): codepoints=${codepointsB64.length} ` +
|
||
`offsets=${offsetsB64.length} wide=${wideFlagsB64.length} pixels=${pixelsB64.length}`,
|
||
);
|
||
|
||
const banner = `// AUTO-GENERATED by scripts/gen-atlas.ts — DO NOT EDIT.
|
||
// Regenerate with: pnpm run build:atlas
|
||
// (or ATLAS_PROFILE=practical pnpm run build:atlas to drop Hangul for
|
||
// Workers free-tier deployments under the 1 MB compressed-bundle cap)
|
||
// Source fonts: ${PRIMARY_FONT_LABEL} @ ${PRIMARY_FONT_PX}px primary; ${FALLBACK_FONT_LABEL} @ ${FALLBACK_FONT_PX}px fallback (profile: ${PROFILE})
|
||
// Glyphs: ${found.length} codepoints (${wideCount} wide, ${primaryCount} primary)
|
||
`;
|
||
|
||
const body = `
|
||
/** Latin advance width in pixels. CJK glyphs advance ${2 * cellW}px (= 2 × this). */
|
||
export const ATLAS_CELL_W = ${cellW};
|
||
/** Cell height in pixels. */
|
||
export const ATLAS_CELL_H = ${cellH};
|
||
/** Distance from cell top to baseline. */
|
||
export const ATLAS_ASCENT = ${ascent};
|
||
/** Distance from baseline to cell bottom. */
|
||
export const ATLAS_DESCENT = ${descent};
|
||
/** Primary font size used when rasterizing ASCII/code glyphs. */
|
||
export const ATLAS_FONT_PX = ${PRIMARY_FONT_PX};
|
||
/** Font family label used at build time. Renderer never re-loads the font. */
|
||
export const ATLAS_FONT_FAMILY = ${JSON.stringify(FONT_FAMILY_LABEL)};
|
||
/** Profile used to build this atlas. */
|
||
export const ATLAS_PROFILE = ${JSON.stringify(PROFILE)};
|
||
|
||
// ---- base64 blobs (decoded once at module init) --------------------------
|
||
|
||
const CODEPOINTS_B64 = ${JSON.stringify(codepointsB64)};
|
||
const OFFSETS_B64 = ${JSON.stringify(offsetsB64)};
|
||
const WIDE_FLAGS_B64 = ${JSON.stringify(wideFlagsB64)};
|
||
const PIXELS_B64 = ${JSON.stringify(pixelsB64)};
|
||
|
||
/** Decode base64 → Uint8Array. Workers-safe (no Buffer / no node:zlib). */
|
||
function decodeB64(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;
|
||
}
|
||
|
||
function decodeU32(b64: string): Uint32Array {
|
||
const bytes = decodeB64(b64);
|
||
return new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
|
||
}
|
||
|
||
/** Sorted codepoint table. \`ATLAS_CODEPOINTS[rank]\` is the codepoint stored
|
||
* at \`rank\` in OFFSETS / WIDE_FLAGS / PIXELS. */
|
||
export const ATLAS_CODEPOINTS: Uint32Array = /* @__PURE__ */ decodeU32(CODEPOINTS_B64);
|
||
|
||
/** BIT offset into ATLAS_PIXELS for the glyph at each rank. */
|
||
export const ATLAS_OFFSETS: Uint32Array = /* @__PURE__ */ decodeU32(OFFSETS_B64);
|
||
|
||
/** 1 if the glyph at this rank is double-wide (East Asian Wide), 0 otherwise. */
|
||
export const ATLAS_WIDE_FLAGS: Uint8Array = /* @__PURE__ */ decodeB64(WIDE_FLAGS_B64);
|
||
|
||
/** Bit-packed 1-bit pixel data, MSB-first. Runtime extraction:
|
||
* bitIdx = OFFSETS[rank] + row * srcW + col
|
||
* byteIdx = bitIdx >>> 3
|
||
* bitOff = 7 - (bitIdx & 7)
|
||
* pixel = (ATLAS_PIXELS[byteIdx] >>> bitOff) & 1
|
||
* where srcW is CELL_W (narrow) or 2*CELL_W (wide, per WIDE_FLAGS[rank]). */
|
||
export const ATLAS_PIXELS: Uint8Array = /* @__PURE__ */ decodeB64(PIXELS_B64);
|
||
|
||
/** Number of glyphs in the atlas. */
|
||
export const ATLAS_NUM_GLYPHS = ATLAS_CODEPOINTS.length;
|
||
|
||
/** Binary-search the sparse codepoint table. Returns rank (≥0) or -1 if the
|
||
* codepoint is not in the atlas. Hot path; called once per rendered char. */
|
||
export function atlasRank(codepoint: number): number {
|
||
let lo = 0;
|
||
let hi = ATLAS_CODEPOINTS.length - 1;
|
||
while (lo <= hi) {
|
||
const mid = (lo + hi) >> 1;
|
||
const v = ATLAS_CODEPOINTS[mid]!;
|
||
if (v === codepoint) return mid;
|
||
if (v < codepoint) lo = mid + 1;
|
||
else hi = mid - 1;
|
||
}
|
||
return -1;
|
||
}
|
||
`;
|
||
|
||
writeFileSync(OUT_PATH, banner + body);
|
||
console.log(
|
||
`[gen-atlas] wrote ${OUT_PATH} ` +
|
||
`(${pixels.length} pixel bytes packed from ${totalBits} bits, ${pixelsB64.length} b64 chars; total file ~${Math.round((banner.length + body.length) / 1024)} KB)`,
|
||
);
|