@rophpad/imgen
v0.1.0
Published
Dependency-free, deterministic image generation for the web.
Maintainers
Readme
imgen
Dependency-free image generation for the web. Describe an image as data — or capture it from the DOM — and get a PNG that renders identically on iOS Safari, Chrome Android, desktop, installed PWAs and in-app webviews.
Zero runtime dependencies. Framework-agnostic. The layout and paint core runs without a DOM, so the same code works in a browser, a worker, or Node.
Contents
- Why · Install · Environment support
- The pipeline · Quick start
- Authoring: capture vs. scene
- Scene reference · Layout rules · Fonts · Images
- Output · Delivery
- Framework recipes · Node / server-side
- Extending · Limitations · API
Why
The usual approach — html-to-image, html2canvas, dom-to-image — serialises the DOM into an SVG <foreignObject> and asks the browser to rasterise it. That is the most engine-divergent path on the platform:
| Problem | Consequence |
| ---------------------------------------------------------- | -------------------------------------------------------------------- |
| Web fonts must be inlined into the SVG | WebKit rasterises before the face is live → silent fallback font |
| WebP inside foreignObject | Blank on older iOS → forces per-platform format branches in app code |
| "First rasterise returns blank" race | setTimeout guesswork instead of a guarantee |
| Modern CSS (oklch(), color-mix()) in serialised styles | Renders transparent or black on older WebKit |
| No canvas budget awareness | A high pixel ratio silently produces an empty image |
imgen never asks the browser to re-render anything. It reads values out and repaints them with Canvas 2D primitives — drawImage, fillText, arcTo — which behave identically across engines. That is the entire design.
Install
Install the package from npm:
npm install imgenOr use your preferred package manager:
pnpm add imgen
# or: yarn add imgenThe package has no runtime dependencies and ships as ESM with TypeScript declarations. It works with Vite, webpack, esbuild, Rollup, and Next.js:
import { renderElement, deliverImage } from 'imgen'Requirements: Node 18 or newer for development tooling. Applications should target ES2020 or newer. TypeScript consumers using the browser modules need "lib": ["DOM", "ES2020"] in their tsconfig.
Environment support
Not every module needs a browser. This matters if you render server-side or in a worker.
| Module | Needs | Safe in Node |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| types.ts | nothing | ✅ |
| layout.ts | a Measurer you supply | ✅ |
| measure.ts | cssFont / wrapText / lineHeightOf are pure; createMeasurer() needs a canvas | ✅ (except createMeasurer) |
| render-canvas.ts | any 2D context you pass in | ✅ |
| dsl.ts, css.ts | nothing | ✅ |
| assets.ts | loadAssets needs fetch / document.fonts / createImageBitmap; collectImages, collectFonts, walkScene and naturalSize are pure | ⚠️ pure helpers only |
| capture.ts | a live DOM | ❌ |
| encode.ts | HTMLCanvasElement / OffscreenCanvas | ❌ |
| deliver.ts | navigator, document | ❌ |
The barrel (index.ts) re-exports everything, so in Node import the submodules directly rather than the barrel. This is verified by __tests__/portability.spec.ts, which runs layout and paint under Vitest's node environment with document, window and HTMLElement all undefined.
The pipeline
┌─ captureElement(el) ─┐
│ (from the DOM) │
├──▶ Scene ──▶ loadAssets ──▶ layout ──▶ paint ──▶ encode ──▶ deliver
│ (written by hand) │
└─ scene / DSL ────────┘Assets load before layout, because text metrics and intrinsic image sizes both feed the box calculation. That ordering is why no step needs a timeout.
Quick start
Three entry points, increasing in control:
import { renderElement, renderScene, prepareScene, deliverImage } from 'imgen'
// 1. From a DOM element — one call, nothing to author
const { blob, warnings } = await renderElement(cardEl, { scale: 3 })
// 2. From a scene you wrote
const { blob } = await renderScene(scene, { scale: 3, format: 'png' })
// 3. Assets + layout only, no painting (previews, measuring, inspection)
const { layout, assets } = await prepareScene(scene)
// Then get it to the user
await deliverImage({ blob, fileName: 'card.png', title: 'My card' })Always surface warnings in development — they report font substitution, skipped CSS, scale clamping and CORS problems that would otherwise be invisible:
warnings.forEach((w) => console.warn(w))Authoring: capture vs. scene
Both produce a Scene and share the renderer. Pick per use case.
| | Capture — derive from the DOM | Scene — write it out | | --------------------- | --------------------------------- | -------------------------- | | You write | Your normal markup and CSS | A scene, via the DSL | | Drift risk | None; it's derived | Two things to keep in sync | | Output across devices | Whatever that device laid out | Byte-identical | | Server-side rendering | No, needs a live DOM | Yes, it's just JSON | | Fidelity | Limited to the CSS it reads | Exactly what you specified |
Start with capture. Reach for a scene when you need identical output everywhere, want to render server-side, or hit something capture doesn't support.
Capture
import { renderElement, captureElement } from 'imgen'
const { blob, warnings } = await renderElement(cardEl, { scale: 3 })
// Exclude controls that aren't content
await renderElement(cardEl, {
scale: 3,
ignore: (el) => el.hasAttribute('data-export-ignore'),
})
// Or capture without rendering, to inspect or post-process
const { scene, warnings } = captureElement(cardEl)captureElement is synchronous on purpose: it must observe layout at one instant, and awaiting mid-walk risks the DOM changing underneath it.
Reads: box geometry, background colours and linear-gradient, borders, border radius, box shadow, opacity, <img> (including currentSrc and object-fit), <canvas>, inline <svg> (serialised standalone with currentColor resolved), and text — one node per rendered line via Range.getClientRects(), so the browser's own line breaking is preserved rather than recomputed.
Ignores (each emits a warning): pseudo-elements, CSS transforms, overflow clipping of descendants, text decoration, backdrop filters, radial and conic gradients, per-corner border radii (top-left is used for all four).
Scene, via the DSL
import { col, row, txt, img, font, defineTheme, renderScene, FILL_PARENT } from 'imgen'
const theme = defineTheme({
fonts: {
caption: font({ family: 'Inter', size: 12, leading: 16 }),
display: font({ family: 'Inter', size: 36, leading: 40, weight: 700 }),
},
colors: { brand: '#0924bf', muted: 'rgba(0, 0, 0, 0.5)' },
})
const scene = {
width: 1080,
height: 1350,
background: '#ffffff',
root: col({ padding: 64, gap: 24 }, [
img({ webp: heroWebp, png: heroPng }, { abs: FILL_PARENT, fit: 'cover' }),
theme.text('February 2026', { font: 'caption', color: 'muted' }),
theme.text('128', { font: 'display', color: 'brand' }),
row({ justify: 'between', align: 'center' }, [
txt('Left', { font: theme.fonts.caption }),
txt('Right', { font: theme.fonts.caption }),
]),
]),
}
const { blob } = await renderScene(scene, { scale: 2 })The builders return plain IR nodes, so they mix freely with object literals — adopt them incrementally.
box/col/row— containers. Call ascol(children)orcol(options, children).txt(text, style)/img(src, style)— leaves.abs: { top, left }expands toposition: 'absolute'+inset.justify: 'between' | 'around'expands to the CSS-length spellings.font({ size, leading })takes the line box height in px (how design systems express leading) and converts to the ratio the IR stores.defineTheme({ fonts, colors })givestheme.text(str, { font: 'display', color: 'brand' })with typed tokens — a typo is a compile error, not a wrong colour.theme.fonts.x/theme.colors.xexpose raw values.
Scene reference
A scene is plain, serialisable data:
interface Scene {
width: number // logical size; output is this × scale
height: number
background?: Paint
root: SceneNode
}Three node types:
box— container.padding,direction(row|column),gap,align,justify,background,border,radius,clip.text—textplusfont,color,align,maxLines,ellipsis,letterSpacing.image—srcplusfit(cover|contain|fill),radius,border.
Every node also accepts width / height (number | 'auto' | 'fill'), margin, opacity, shadow, rotate, and position: 'absolute' with inset.
background and color take a colour string or { type: 'linear', angle, stops }. Angle is degrees, 0 = left-to-right, clockwise.
Layout rules
Flexbox-lite, deliberately a subset — single-axis row/column, no wrapping rows, no grid, no percentages. That covers cards completely and keeps the engine small enough to reason about.
'auto'(default) sizes to content;'fill'divides the parent's leftover main-axis space equally.'fill'only has space to divide when the parent's own main size is known; inside an auto-sized parent it behaves as'auto'.- A root
boxwith no declared size fills the scene. Setwidth: 'auto'explicitly to opt out. align: 'stretch'resizes the child after measurement. For children whose content depends on width (wrapping text) usewidth: 'fill', which is honoured during the measure pass.- Absolute children are positioned against the parent's content box and take no space in flow. Giving both
leftandright(ortopandbottom) derives the size.
Fonts
Every variant a scene uses is requested explicitly before layout:
await document.fonts.load('normal 700 96px "Inter"')document.fonts.ready is not sufficient — it resolves when pending loads settle, and a weight the document never requested is not pending, so it resolves immediately and the first paint falls back silently. This is the single most common cause of "the downloaded image has the wrong font".
The loader also detects substitution by measuring against monospace and reports it in warnings, so a wrong-looking image tells you why.
Declare a fallback stack on the FontSpec — it is used both for painting and for substitution detection:
font({ family: 'Inter', size: 24, weight: 700, fallback: ['system-ui', 'sans-serif'] })Images
src accepts a URL, a Blob, an ImageBitmap, an HTMLImageElement, or a format bundle:
img({ webp: heroWebp, png: heroPng })WebP support is probed by decoding a real pixel, so no user-agent branch is needed in app code. (Note the common mistake: canvas.toDataURL('image/webp') tests the encoder and reports false on browsers that decode WebP fine.)
Assets are fetched with CORS and decoded to an ImageBitmap — which never taints the canvas, and guarantees pixels are ready before the first draw. If a host sends no CORS headers the loader falls back to an <img> and warns; export then fails with an actionable message rather than a bare SecurityError.
Decoded images are cached by URL across renders. Call clearImageCache() if sources change at runtime.
Output
const { blob, width, height, scale, warnings } = await renderScene(scene, {
scale: 3, // device-pixel multiplier
format: 'png', // 'png' | 'jpeg' | 'webp'
quality: 0.92, // jpeg/webp only
})Scale is clamped automatically. WebKit caps total canvas area at ~16.7M device pixels and fails by rendering blank, not by throwing. renderScene reduces the requested scale to fit and reports it in warnings; the returned scale tells you what was actually used.
For an on-screen preview, drawSceneOnCanvas(scene, canvasEl) paints with the same painter as export, so preview and file cannot drift.
Delivery
<a download> is not a cross-platform save button:
- iOS Safari largely ignores
downloadonblob:URLs and navigates instead; in an installed PWA it can do nothing at all. - In-app webviews (Instagram, Facebook, WhatsApp) block downloads outright.
- Revoking the object URL in the same tick cancels the download that just started.
So deliverImage runs a capability ladder:
navigator.share({ files })— the native share sheet; the only reliable route to the iOS camera roll and the best path on Android.<a download>— desktop and Android. Skipped on iOS and in-app webviews, where it does nothing.- Long-press overlay — full-screen image with save instructions. The only thing that works inside the Instagram and Facebook webviews.
const result = await deliverImage({
blob,
fileName: 'card.png',
title: 'My card',
text: 'Optional share text',
longPressMessage: 'Press and hold to save.', // localise this
closeLabel: 'Close',
})
if (!result.ok) console.error(result.reason)A dismissed share sheet returns { method: 'cancelled', ok: true } so the ladder does not fall through to a download the user declined. copyImageToClipboard(blob) and presentImageForSaving(blob) are available individually.
iOS gotcha:
navigator.shareneeds an intact user activation, and a slow render between the tap and the call can lose it. Render ahead of time and calldeliverImagein the click handler, rather thanrenderAndDeliver.
Framework recipes
The library is plain functions over DOM elements and data — there is no adapter to write.
Vanilla
document.querySelector('#save').addEventListener('click', async () => {
const { blob } = await renderElement(document.querySelector('#card'), { scale: 3 })
await deliverImage({ blob, fileName: 'card.png' })
})React
const cardRef = useRef<HTMLDivElement>(null)
const [blob, setBlob] = useState<Blob | null>(null)
// Pre-render so the tap handler keeps its user activation
useEffect(() => {
if (cardRef.current) renderElement(cardRef.current, { scale: 3 }).then((r) => setBlob(r.blob))
}, [data])
const onSave = async () => {
const ready = blob ?? (await renderElement(cardRef.current!, { scale: 3 })).blob
await deliverImage({ blob: ready, fileName: 'card.png' })
}
return <div ref={cardRef}>…</div>Vue
<script setup lang="ts">
const card = ref<HTMLElement | null>(null)
const blob = ref<Blob | null>(null)
watch(data, async () => {
if (card.value) blob.value = (await renderElement(card.value, { scale: 3 })).blob
})
const save = async () => {
const ready = blob.value ?? (await renderElement(card.value!, { scale: 3 })).blob
await deliverImage({ blob: ready, fileName: 'card.png' })
}
</script>
<template><div ref="card">…</div></template>Svelte
<script lang="ts">
let card: HTMLElement
let blob: Blob | null = null
$: if (card && data) renderElement(card, { scale: 3 }).then((r) => (blob = r.blob))
const save = async () => {
const ready = blob ?? (await renderElement(card, { scale: 3 })).blob
await deliverImage({ blob: ready, fileName: 'card.png' })
}
</script>
<div bind:this={card}>…</div>The pattern is the same everywhere: pre-render on data change, deliver on click.
Node / server-side rendering
Scenes are JSON and the core is DOM-free, so a scene can be rendered on a server for guaranteed-identical output, OG preview images, or clients where nothing works (in-app webviews).
This needs a thin adapter — about 30 lines, not shipped, because it depends on which canvas backend you choose (@napi-rs/canvas, skia-canvas, node-canvas):
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas'
import { layoutScene } from 'imgen/layout'
import { paintScene, type AnyCanvasContext } from 'imgen/render-canvas'
import { cssFont, type Measurer } from 'imgen/measure'
import { collectImages } from 'imgen/assets'
import type { Drawable, ResolvedAssets, Scene } from 'imgen/types'
GlobalFonts.registerFromPath('./fonts/Inter-Bold.ttf', 'Inter')
// The Measurer is the only piece layout needs from the environment.
function nodeMeasurer(): Measurer {
const ctx = createCanvas(1, 1).getContext('2d')
return {
measure(text, font, letterSpacing = 0) {
ctx.font = cssFont(font)
const m = ctx.measureText(text)
const spacing = letterSpacing * Math.max(0, [...text].length - 1)
return {
width: m.width + spacing,
ascent: m.actualBoundingBoxAscent || font.size * 0.8,
descent: m.actualBoundingBoxDescent || font.size * 0.2,
}
},
}
}
export async function renderSceneToPng(scene: Scene, scale = 3): Promise<Buffer> {
// Resolve images yourself — assets.ts is browser-only.
const images = new Map<ReturnType<typeof collectImages>[number], Drawable>()
for (const node of collectImages(scene)) {
if (typeof node.src === 'string') {
images.set(node, (await loadImage(node.src)) as unknown as Drawable)
}
}
const assets: ResolvedAssets = { images, fonts: [], warnings: [] }
const layout = layoutScene(scene, {
measurer: nodeMeasurer(),
intrinsicImageSize: (node) => {
const image = images.get(node)
return image ? { width: image.width, height: image.height } : null
},
})
const canvas = createCanvas(scene.width * scale, scene.height * scale)
paintScene(canvas.getContext('2d') as unknown as AnyCanvasContext, scene, layout, assets, scale)
return canvas.toBuffer('image/png')
}Two casts are needed because Node canvas types are structurally similar but not identical to the DOM's. They are safe: paintScene only uses the standard 2D subset, and naturalSize reads width/height, which Node image objects provide.
Capture mode cannot run server-side — it needs real layout. If you want the server to render what a browser laid out, capture on the client and POST the resulting scene as JSON.
Extending
Custom Measurer — the seam that makes layout portable. Anything providing measure(text, font, letterSpacing) → { width, ascent, descent } works: a real canvas, a Node backend, or a fake one in tests.
Custom paint backend — paintScene(ctx, …) accepts any object implementing the Canvas 2D subset it uses (save/restore, scale, translate, rotate, beginPath, rect, moveTo, arcTo, closePath, fill, stroke, clip, clearRect, fillRect, drawImage, fillText, measureText, createLinearGradient, plus the usual state properties).
Worker rendering — createRenderTarget prefers OffscreenCanvas where available. Because layout output is plain data and ImageBitmap is transferable, moving paint into a worker is a drop-in: run prepareScene on the main thread (it needs document.fonts), then post the layout and bitmaps across.
New node types — add a variant to SceneNode in types.ts, a measure branch in layout.ts, and a paint branch in render-canvas.ts. The three are deliberately the only places that switch on node type.
Limitations
Known and deliberate, so you can decide before adopting:
- Layout is a flexbox subset — no wrapping rows, grid, percentages, or
aspect-ratio. - No vector-path node. Inline SVG works via capture; a hand-authored scene cannot draw arbitrary paths. Use an image.
rotatedoes not affect layout — it is applied at paint time about the node's centre.- Single shadow and uniform border per node; no per-side borders or multiple shadows.
clipclips to the box — no arbitrary clip paths.- Capture is a snapshot of one device's layout, not a portable description.
- RTL and complex text shaping follow whatever the underlying canvas does; the wrapper is a simple greedy line breaker.
API
| Function | Purpose |
| ------------------------------------------------------------------------------------- | -------------------------------------------- |
| renderElement(el, opts) | Capture + render in one call |
| renderScene(scene, opts) | Render a scene to a blob |
| renderAndDeliver(scene, delivery, opts) | Render then run the delivery ladder |
| prepareScene(scene) | Resolve assets + compute layout, no painting |
| drawSceneOnCanvas(scene, canvas, opts) | Paint onto an existing canvas (previews) |
| captureElement(el, opts) | DOM → { scene, warnings } |
| deliverImage(opts) | Share → download → long-press ladder |
| shareImage / downloadImage / copyImageToClipboard / presentImageForSaving | Individual delivery paths |
| box / col / row / txt / img / font / defineTheme | Authoring DSL |
| layoutScene(scene, opts) | Pure layout |
| paintScene(ctx, scene, layout, assets, scale) | Pure paint |
| loadAssets(scene) / clearImageCache() | Asset resolution |
| safeScale(w, h, requested) / encodeCanvas(canvas, format, quality) | Output control |
| isIOSLike / isInAppBrowser / isStandalonePWA / supportsWebP / maxCanvasArea | Capability probes |
Module map
| File | Responsibility |
| ------------------ | -------------------------------------------------- |
| types.ts | Scene IR |
| dsl.ts | Authoring helpers: builders, font, defineTheme |
| capture.ts | DOM → Scene |
| css.ts | Pure computed-CSS parsers (used by capture) |
| platform.ts | Capability probes, canvas budget |
| measure.ts | Text metrics, line breaking |
| layout.ts | Pure flexbox-lite engine |
| assets.ts | Font + image loading |
| render-canvas.ts | Canvas 2D painter |
| encode.ts | Canvas allocation, blob encoding |
| deliver.ts | Share / download / clipboard ladder |
| index.ts | Public API |
Testing
The pure core is testable without a browser, which is most of the value:
npm install
npm run typecheck
npm test
npm run buildBefore publishing, run npm pack --dry-run to inspect the package contents. Releases are published with npm publish; the scoped package is configured for public access.
layout.spec.ts— the full engine against a fakeMeasurer, no canvas or DOM.render.spec.ts— the paint path against a recording stub context.css.spec.ts— computed-value parsers, where capture's real bugs live.dsl.spec.ts— builder and theme expansion.portability.spec.ts— runs layout and paint under thenodeenvironment to prove the core touches no DOM globals.
Capture's geometry half can only be tested in a real browser: jsdom has no layout engine, so getBoundingClientRect returns zeros. Verify capture on a device.
