@gramforge/images
v0.1.0
Published
Typed card-template inputs in, PNG `Uint8Array` out — a browserless renderer for the six Observatory agent notification cards (status, fleet overview, needs-input, completion, failure, chart). Node-primary, npm-only.
Readme
@gramforge/images
Typed card-template inputs in, PNG Uint8Array out — a browserless renderer for
the six Observatory agent notification cards (status, fleet overview, needs-input,
completion, failure, chart). Node-primary, npm-only.
This package produces photo bytes. It knows nothing of the Telegram Bot API:
no chat_id, no sendPhoto payload, no upload call. Wrapping the bytes into a
Bot API request is the transport layer's job. It imports @gramforge/core /
@gramforge/render only for typed text needs and never a transport package.
Install
pnpm add @gramforge/imagesQuick start
import { renderCard } from "@gramforge/images";
const result = await renderCard({
template: "status",
agentName: "builder-agent",
level: "ok", // "ok" | "warn" | "error" — a discriminated enum, not an isError flag
headline: "Build passed",
detail: { kind: "text", value: "42 files compiled" }, // or { kind: "none" }
timestampIso: "2026-07-08T12:00:00.000Z"
});
// Result is a discriminated union — never a thrown exception for expected failures.
if ("hit" in result) {
// Cache short-circuit: an identical card was already uploaded for this bot.
await sendPhotoByFileId(result.fileId);
} else if (result.ok) {
await uploadPhotoBytes(result.bytes); // meta: backend, contentHash, network, renderMs, w/h
} else {
switch (result.error.kind) {
case "invalid-template-input": /* result.error.issues */ break;
case "renderer-unavailable": /* result.error.attempted backends */ break;
case "rasterize-failed": /* result.error.message */ break;
case "chart-spec-invalid": /* result.error.issues */ break;
}
}Every template also has a builder that returns the node tree plus a render()
helper: statusCard, fleetOverviewCard, needsInputCard, completionCard,
failureCard, chartCard. needsInputCard rejects an empty options list at the
type level (non-empty array).
Backends: Takumi primary, satori+sharp fallback
Rendering goes through a pluggable takumi backend:
| Backend | Package | Runtime | Notes |
| --- | --- | --- | --- |
| takumi-native | @takumi-rs/core (napi) | Node | Primary. Node tree → PNG directly (~11 ms warm). |
| takumi-wasm | @takumi-rs/wasm | browser / Deno / Node | Portable. wasm binary + font buffers are injected — no node: builtins, no fs. This is what lets a showcase render cards client-side. |
selectBackend(options?) returns a discriminated union describing what will
serve renders: { kind: "takumi-native" | "takumi-wasm"; backend } or
{ kind: "fallback"; reason }. Native availability is capability-detected once
per process (the napi binding is dynamic-imported and the decision cached).
Callers may force a backend via { force: "takumi-native" | "takumi-wasm" | "fallback" }.
When neither takumi backend can serve the environment (native module load
failure / unsupported platform, or an explicit force), rendering falls back to
satori (SVG) → sharp (PNG) and fires warnDegraded("images.fallback", …)
exactly once. The wasm rasterizer is @resvg/resvg-wasm (never resvg-js on
native — it is ~4.6× slower as a satori rasterizer; sharp is used there).
Platform prebuilts (musl + glibc)
The native backend's binaries ship via Takumi's own napi optional-dependency
mechanism: linux-x64-gnu, linux-arm64-gnu, linux-x64-musl,
linux-arm64-musl, darwin-x64, darwin-arm64, win32-*. On a platform with
no matching prebuilt (or in a musl/Alpine container missing the musl build), the
native probe fails, meta.backend reports satori-sharp, and rendering still
succeeds via the fallback. renderer-unavailable is a first-class, testable
RenderFailure variant.
Charts
Charts render with ECharts SSR (init(null, null, { ssr: true, renderer:
"svg" })) → SVG → PNG (sharp on native / @resvg/resvg-wasm on the portable
path). No Puppeteer, Playwright, or headless-Chromium anywhere in the graph.
import { renderChart } from "@gramforge/images";
const result = await renderChart({
kind: "bar", // "bar" | "line"
title: "Throughput",
categories: ["mon", "tue", "wed"],
series: [{ name: "tasks", values: [3, 7, 5] }]
});chartCard and fleetOverviewCard call renderChart internally and splice the
PNG back in as an img node, so the whole card still goes through a single
takumi/satori pass. If the chart cannot be rasterized, the card degrades to a
typed placeholder and still returns ok: true — a chart failure never fails
the card.
file_id cache
A Telegram file_id is valid only for the bot that uploaded it, so the cache is
keyed by (ContentHash, BotId). FileIdCacheStore is an injected interface
(get/set); a Map-backed createInMemoryFileIdCacheStore() ships as the
default, and callers substitute a persistent (SQLite/Redis) store without any
change to renderCard.
import { renderCard, createInMemoryFileIdCacheStore, recordUpload } from "@gramforge/images";
const store = createInMemoryFileIdCacheStore();
const r = await renderCard(input, { lookup: store, botId });
if ("hit" in r) {
// Cache hit: rendering was skipped entirely; reuse r.fileId.
} else if (r.ok) {
const fileId = await uploadAndGetFileId(r.bytes); // caller's job
await recordUpload(store, r.meta.contentHash, botId, fileId);
}renderCard never calls the Bot API — it only computes the content hash,
consults the store, and (on a hit) returns a CachedFileHit without rendering.
Recording an uploaded file_id back into the store is the caller's job via
recordUpload. The in-memory store does not persist across process restarts
(documented default, not a guarantee).
Card rendering is deterministic, so the cache key (and meta.contentHash) is a
stable SHA-256 of the canonical card input, which is pre-render computable — that
is what lets a cache hit truly skip the render pipeline. The separate
computeContentHash(bytes) utility hashes raw PNG bytes (used by renderChart
and available to callers who want byte-level hashing). Both use portable Web
Crypto (crypto.subtle), not node:crypto.
Fonts and the color-emoji caveat
A Latin/monospace font (DejaVu Sans) is bundled at src/assets/font-latin.bin
and loaded via readFileSync at first use — never a network fetch — so plain
ASCII/Latin cards render fully offline.
Color emoji require a network font fetch on first use. Takumi/satori resolve
color-emoji glyphs from a remote font; the bundled font covers Latin/monospace
only. This is a documented caveat, not a bug: containsColorEmoji(text) and
RenderMeta.network ({ kind: "offline" } vs { kind: "emoji-font-fetch",
codepoints }) make it observable ahead of and after a render. Offline with emoji
present degrades to a missing glyph, not a crash.
Authoring constraints
Card node trees are built only through the flexbox-safe typed builders (div,
row, col, text, img). CardStyle exposes no CSS Grid and no free-form
style: string escape hatch, so a template that type-checks renders consistently
on both Takumi and satori.
House rules
zod schemas are the single source of truth; presence is modeled with
discriminated unions (never .optional()/.nullable()), all unions are matched
with ts-pattern .exhaustive(), and expected failures are typed
{ kind }-tagged DUs rather than thrown exceptions.
Distribution
npm-only. The native napi module + sharp + Node font-loading APIs are
incompatible with JSR's Deno-portability requirement, so there is no jsr.json.
The wasm backend and the tree/schema modules are kept free of node: builtins so
a later @gramforge/images-wasm JSR entry can lift them out.
