Files
arah2/routing/scripts/build-glyphs.mjs
araharah-executor 9c50fb033e feat(06-07): committed 8-glyph PNG set rasterized from the app's SVG sources (D-12)
- routing/scripts/build-glyphs.mjs: maintainer shell (Usage/Exit codes/
  collect-then-write — a missing/invalid source exits 1 with the kind named
  and nothing written); recolors currentColor → #EB8008 in-memory and
  injects a full-bleed #1A1A1A plate (stroke="none" — it must not inherit
  the root's orange stroke); the committed SVGs are THE single source and
  stay untouched
- sharp 0.35.5 as a routing devDependency (maintainer-only, zero runtime
  deps); 'glyphs' script added
- 8× 256×256 PNGs committed (web/public/glyphs/) — the chat turn-card's
  sendPhoto URL rides the Worker's static handler; two render bugs fixed
  en route: density multiplied the explicit size attrs into 2048² (dropped),
  and the plate inherited the root stroke (orange frame — stroke="none")
- SVG sources verified unchanged after the run
2026-10-24 19:53:09 +08:00

116 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/*
* build-glyphs.mjs — rasterize the 8 turn-card glyphs from the app's SVG sources (06-07, D-12).
* ------------------------------------------------------------------------------------------------
* A MAINTAINER-TIME REPO TOOL in the fetch-route-fixtures.mjs / upload-road-graph.mjs pattern:
* it is NOT a runtime dependency. Nothing in bot/src/, web/src/, or routing/src/ imports it;
* no deploy, test, CI run, or vite build ever runs it. sharp is a devDependency of the routing
* workspace only (RESEARCH Installation — zero runtime deps added).
*
* D-12 BOUNDARY + SINGLE-SOURCE LAW: the committed web/src/guidance/glyphs/*.svg are THE
* source — the SAME paths the app renders inline (banner 32×32, list rows 24×24). The PNGs
* are their rasterization: committed once, reused everywhere (the chat turn-card's sendPhoto
* fetches web/public/glyphs/<kind>.png from the Worker's static handler). The recolor is
* IN-MEMORY ONLY — the committed SVGs keep their currentColor law (theme-following in-app
* rendering) and are never rewritten by this tool.
*
* Colors are DSGN-01 build constants copied verbatim from theme/src/tokens.css (--color-signal-orange,
* --color-matte-black) — the script is not runtime CSS, hex literals are its interface.
*
* Mechanically, per kind: read the SVG → substitute currentColor → STROKE → inject a full-bleed
* 32×32 PLATE rect as the FIRST child (glyph strokes paint over it) → set width/height SIZE
* attributes → render 256×256 PNG via sharp. COLLECT-THEN-WRITE: every source is read and
* rendered BEFORE any file is written — a missing/invalid source or a failed render exits 1
* with the kind named and NO partial writes (the fixture-precompute precedent).
*
* Usage:
* npm run glyphs -w routing # rebuild all 8 web/public/glyphs/*.png
* node scripts/build-glyphs.mjs # same, direct
*
* Exit codes: 0 all 8 PNGs rendered and written; 1 missing/invalid source SVG, failed render,
* or a size/plate sanity-check failure (no file written for a failed batch).
*/
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); // routing/scripts
const SRC_DIR = resolve(here, "..", "..", "web", "src", "guidance", "glyphs");
const OUT_DIR = resolve(here, "..", "..", "web", "public", "glyphs");
/** The 8 turn-card kinds — the GlyphKindName union in bot/src/guidance/cron.ts
* (structurally RouteStep.kind, one authority with engine A). */
const GLYPH_KINDS = ["depart", "straight", "left", "right", "slight-left", "slight-right", "uturn", "arrive"];
/** DSGN-01 tokens, verbatim: signal-orange stroke on a matte-black full-bleed plate. */
const STROKE = "#EB8008";
const PLATE = "#1A1A1A";
const SIZE = 256; // output pixels — chat renders it ~48–96px; the oversize keeps edges crisp
const SOURCE_GRID = 32; // the committed sources' viewBox (3px stroke, square caps)
const MIN_BYTES = 500; // a flat-color 256×256 PNG is ≫ 500 B; smaller means a broken render
function fail(message) {
console.error(`build-glyphs: ${message}`);
process.exit(1);
}
/* ————— collect: read + transform + render EVERYTHING before any write ————— */
let sharp;
try {
sharp = (await import("sharp")).default;
} catch {
fail("sharp is not importable — run `npm install -D -w routing sharp` (maintainer devDependency only)");
}
const rendered = [];
for (const kind of GLYPH_KINDS) {
const srcPath = resolve(SRC_DIR, `${kind}.svg`);
let source;
try {
source = readFileSync(srcPath, "utf8");
} catch {
fail(`missing source SVG for kind "${kind}": ${srcPath} — nothing written`);
}
if (!/<svg\b[^>]*>/.test(source)) {
fail(`invalid source SVG (no <svg> root) for kind "${kind}": ${srcPath} — nothing written`);
}
if (!source.includes("currentColor")) {
fail(`source SVG for kind "${kind}" lost its currentColor law (theme-following rendering) — refusing to rasterize; nothing written`);
}
// Recolor + plate + size — in-memory only; the committed source is untouched.
const svg = source
.replaceAll("currentColor", STROKE)
.replace(/<svg\b/, `<svg width="${SIZE}" height="${SIZE}"`)
.replace(/<svg\b[^>]*>/, (m) => `${m}<rect width="${SOURCE_GRID}" height="${SOURCE_GRID}" fill="${PLATE}" stroke="none"/>`);
if (svg.includes("currentColor")) {
fail(`recolor incomplete for kind "${kind}" — nothing written`);
}
let png;
try {
// The width/height SIZE attributes already govern the raster size — an
// explicit density here would MULTIPLY with them (256 × 576/72 = 2048),
// so none is set.
png = await sharp(Buffer.from(svg)).png().toBuffer();
} catch (err) {
fail(`sharp render failed for kind "${kind}": ${err?.message ?? err} — nothing written`);
}
if (png.length < MIN_BYTES) {
fail(`render for kind "${kind}" is ${png.length} bytes (< ${MIN_BYTES}) — a broken/empty plate; nothing written`);
}
rendered.push({ kind, png, srcPath });
}
/* ————— write: all 8 rendered — now commit the batch ————— */
mkdirSync(OUT_DIR, { recursive: true });
for (const { kind, png } of rendered) {
const outPath = resolve(OUT_DIR, `${kind}.png`);
writeFileSync(outPath, png);
console.log(`build-glyphs: wrote ${outPath} (${statSync(outPath).size} bytes)`);
}
console.log(`build-glyphs: ${rendered.length}/${GLYPH_KINDS.length} glyphs rasterized from the committed SVG sources`);