npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rophpad/imgen

v0.1.0

Published

Dependency-free, deterministic image generation for the web.

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

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 imgen

Or use your preferred package manager:

pnpm add imgen
# or: yarn add imgen

The 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 as col(children) or col(options, children).
  • txt(text, style) / img(src, style) — leaves.
  • abs: { top, left } expands to position: '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 }) gives theme.text(str, { font: 'display', color: 'brand' }) with typed tokens — a typo is a compile error, not a wrong colour. theme.fonts.x / theme.colors.x expose 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.
  • texttext plus font, color, align, maxLines, ellipsis, letterSpacing.
  • imagesrc plus fit (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 box with no declared size fills the scene. Set width: 'auto' explicitly to opt out.
  • align: 'stretch' resizes the child after measurement. For children whose content depends on width (wrapping text) use width: '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 left and right (or top and bottom) 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 download on blob: 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:

  1. navigator.share({ files }) — the native share sheet; the only reliable route to the iOS camera roll and the best path on Android.
  2. <a download> — desktop and Android. Skipped on iOS and in-app webviews, where it does nothing.
  3. 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.share needs an intact user activation, and a slow render between the tap and the call can lose it. Render ahead of time and call deliverImage in the click handler, rather than renderAndDeliver.

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 backendpaintScene(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 renderingcreateRenderTarget 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.
  • rotate does 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.
  • clip clips 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 build

Before 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 fake Measurer, 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 the node environment 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.