@basenative/og-image

中文文档
3.0.0发布于 2026/10/31Apache-2.0TypeScriptES Module构建来源已验证

Runtime-agnostic OG / social share PNG renderer — SVG card scenes rasterized by resvg-wasm, with CDN+KV-cached fonts; runs on Cloudflare Workers, Node, Deno and Bun

原始使用文档
文档目录17 节
  1. Read this first if you are upgrading from 0.2.x
  2. Why
  3. Install
  4. Quick start
  5. Cards
  6. brandCard(opts) → string (SVG)
  7. Font coverage is a real limit
  8. OG_SIZE
  9. Fonts
  10. Caching without KV
  11. Supplying the WASM module
  12. Runtime detection
  13. Caching the images themselves
  14. API
  15. Theming
  16. Errors
  17. License

@basenative/og-image

Runtime-agnostic OG / social share PNG renderer — SVG card scenes rasterized by resvg-wasm, with CDN + KV-cached fonts. Runs on Cloudflare Workers, Node, Deno and Bun.

Part of the BaseNative ecosystem — a signal-based web runtime over native HTML.

Renders 1200x630 PNGs at request time for og:image, twitter:image, and arbitrary share-card endpoints.

Read this first if you are upgrading from 0.2.x

renderPng — the satori entry point — has moved to a separate export:

-import { renderPng } from "@basenative/og-image";
+import { renderPng } from "@basenative/og-image/satori";

and satori is now an optional peer dependency: install it yourself if you use that path. On Cloudflare Workers you cannot; use renderCard/renderSvg from the main entry instead, which is what this release exists to provide.

Why

Every version of this package up to and including 0.2.2 could not execute on Cloudflare Workers at all, despite being written for them. satori imports harfbuzzjs (its text shaper) unconditionally, and harfbuzz's emscripten loader asks two environment questions in an order workerd answers wrongly. Reproduced against real workerd (wrangler dev, compatibility_date 2026-04-23, nodejs_compat):

  1. TypeError: Cannot read properties of undefined (reading 'href') — the loader sees WorkerGlobalScope, concludes "browser worker", and reads self.location.href. workerd defines WorkerGlobalScope and self, and defines no location.
  2. Shim location ahead of it and you get ReferenceError: __dirname is not defined — nodejs_compat gives workerd a process.versions.node, so the loader next concludes "Node" and goes looking for hb.wasm on a filesystem that does not exist. wrangler does not bundle that file, and the remaining branch compiles WASM from a buffer, which the embedder refuses outright (Wasm code generation disallowed by embedder — also measured).

Fixing (2) requires harfbuzzjs to accept emscripten's Module.instantiateWasm hook so a statically imported .wasm can be handed in. hb.js honours the hook; harfbuzzjs's entry calls hb() with no arguments, so no downstream package can supply it. A wrangler [alias] cannot stand in either — aliasing harfbuzzjs rewrites its subpaths, so the replacement can no longer resolve the real hb.js/hb.wasm.

So the fix is to not need a text shaper. @resvg/resvg-wasm does its own shaping, and it is a single static .wasm that Workers accept. A card is now an SVG string, and the layout engine is text.js — arithmetic over measured glyph advances, which runs anywhere.

Install

npm install @basenative/og-image
# only if you use the satori entry point:
npm install satori

Quick start

// src/worker.js — Cloudflare Worker
import { renderCard, pngHeaders } from "@basenative/og-image";

export default {
  async fetch(request, env) {
    const png = await renderCard(
      {
        title: "Northside Electrical",
        subtitle: "Electrical — Manchester, NH",
        badge: "Licensed electrician",
        brand: "northside.example.com",
      },
      env,
    );
    return new Response(png, { headers: pngHeaders() });
  },
};

Measured on wrangler dev: ~730ms on a cold isolate (two font fetches plus WASM init), 26-45ms warm.

Cards

brandCard(opts) → string (SVG)

The generic entity card: a name, a qualifying line, an optional badge, and a brand line pinned to the bottom.

import { brandCard, renderSvg } from "@basenative/og-image";

const svg = brandCard({
  title: "Northside Electrical",
  titleFallback: "northside",          // used if `title` has no letter or digit
  subtitle: "Electrical — Manchester, NH",
  badge: "Licensed electrician",
  brand: "northside.example.com",
  theme: { accent: "#4EAF7C" },
  width: 1200,
  height: 630,
});
const png = await renderSvg(svg, env);

No input can break the layout. Every run is fitted to a width budget and the whole stack is fitted to the height between the top padding and the brand line. An empty title, a 200-character title, a title with no spaces at all, RTL script, markup, a control character and a lone surrogate are all covered by tests, and all render to a valid 1200x630 PNG.

Text handling worth knowing about:

  • Shrink-to-fit then wrap, up to three lines, then ellipsis.
  • Soft breaks after -, /, _ and ., so a slugged name breaks at greenleaf- / landscaping rather than mid-word.
  • Grapheme-safe truncation — never emits a lone surrogate, which would make the document invalid XML.
  • RTL sets direction="rtl" when the content is predominantly RTL script.
  • XML-illegal characters are removed, not escaped — a stray control character in a database column would otherwise turn one bad row into a 500 on a public page.

Font coverage is a real limit

The rasterizer draws only glyphs present in the font buffers it is given, and the default is a Latin Inter. A CJK, Devanagari or Arabic title measures and lays out correctly and then renders as .notdef boxes. brandCard limits the damage — it always draws the brand line, so the card still identifies its subject — but if you serve those scripts, supply a covering face:

await renderCard(card, env, {
  fonts: { urls: { 600: "https://…/NotoSansArabic-SemiBold.ttf" } },
});

titleFallback covers the narrower case of a name with no word character at all (an emoji-only business name): the fallback is drawn instead.

OG_SIZE

{ width: 1200, height: 630 } — above Facebook's 600x315 minimum, exactly the 1.91:1 ratio it documents, and inside the 2:1..1:1 window X requires for summary_large_image.

Fonts

Two render paths need two formats, and getting it wrong is silent:

PathFormatSourceWhy
renderSvg / renderCardttf@expo-google-fonts/* (upstream Google TTFs at immutable npm versions)resvg's fontdb only parses raw sfnt. Hand it a .woff and it loads zero faces and draws nothing — a correctly sized card with the text missing.
@basenative/og-image/satoriwoff@fontsource/*satori decompresses WOFF itself; the files are ~5x smaller.
import { defineFonts } from "@basenative/og-image";

defineFonts({
  family: "Inter",
  weights: [600, 800],
  format: "ttf",
  cdnVersion: "0.2.3",
  cacheBinding: "OG_CACHE", // KV binding name; `null` = deliberately none
  cacheKeyPrefix: "font:",
  urls: { 600: "https://…/Custom-SemiBold.ttf" }, // per-weight override
  buffers: { 600: bytes },                         // skip the network entirely
});

Cache keys include the format and CDN version, so a WOFF cached by the satori path can never be served to the rasterizer path.

Caching without KV

Fonts are fetched with cf: { cacheTtl: 86400, cacheEverything: true }, so Cloudflare's own edge cache absorbs cold-isolate misses even with no KV namespace. Pass cacheBinding: null to say that is deliberate and silence the warning. With KV:

wrangler kv namespace create OG_CACHE
[[kv_namespaces]]
binding = "OG_CACHE"
id = "<id>"

Supplying the WASM module

The package resolves the resvg .wasm itself: a static import under wrangler/esbuild (which compiles it ahead of time — Workers refuse to compile WASM from a buffer), and a filesystem read under Node. If neither fits your runtime or bundler, hand it in:

import wasm from "@resvg/resvg-wasm/index_bg.wasm"; // your bundler
import { provideResvgWasm } from "@basenative/og-image";

provideResvgWasm(wasm); // WebAssembly.Module, bytes, a Response, or a promise

Call it at module scope, before the first render. What you provide wins over the built-in resolution, which is then never consulted.

Runtime detection

detectRuntime() is exported because the order of its checks is the whole subject of this release:

import { detectRuntime, isWorkerd, hasFilesystem } from "@basenative/og-image";

detectRuntime(); // "workerd" | "node" | "deno" | "bun" | "browser" | "unknown"
  • workerd is checked before Node, because nodejs_compat makes it report process.versions.node.
  • workerd is checked before browser, because it defines WorkerGlobalScope and self. The signal that separates it from a real browser worker is that a browser worker has a location.

Caching the images themselves

pngHeaders() marks the response immutable for a year by default, because the intended addressing scheme is content-addressed: put a hash of the card's own text in the URL, so renaming the subject changes the URL and no crawler is left holding a stale card. If your URL is not content-addressed, pass { immutable: false }.

API

ExportPurpose
renderCard(card, env?, opts?)Build a brand card and rasterize it.
renderSvg(svg, env?, opts?)Rasterize any SVG string.
brandCard(opts)Build the card scene without rendering.
pngHeaders(opts?)Content-Type + Cache-Control.
provideResvgWasm(source)Supply the WASM module explicitly.
defineFonts, loadFontBuffers, loadFonts, fontUrl, fontCacheKeyFonts.
detectRuntime, isWorkerd, hasFilesystem, canCompileWasmFromBytesEnvironment.
escapeXml, num, rect, textLine, textBlock, headline, clippedLine, svgDocSVG primitives, for custom scenes.
graphemes, estimateWidth, isRtl, hasWordCharacter, truncateGraphemes, truncateToWidth, wrapToLines, fitTextText layout.
OgImageErrorThrown with a stable code.
box, text, tile, tileGrid, parseGrid, theme, defaultTheme, el, defaultPreset, articlePreset, scoreCardPreset, presetssatori scene DSL — data only; render via the /satori entry.

@basenative/og-image/satori additionally exports renderPng(scene, env?, opts?).

Theming

brandCard({
  title: "Hello",
  theme: { bg: "#0a0a0a", fg: "#fafafa", accent: "#7c3aed", muted: "#71717a" },
});

Tokens: bg, fg, accent, muted, tile, letter, green, yellow, absent, empty.

Errors

OgImageError carries a stable code:

CodeMeaning
satori-unsupported-on-this-runtimeYou called the satori entry point on Workers.
wasm-source-unavailableNeither the import map nor provideResvgWasm yielded a module.
wasm-source-invalidprovideResvgWasm was handed something that is not a module, bytes or a Response.
font-fetch-failedThe CDN refused, or no TTF is known for that family/weight.
no-fontsThe resolved weight list was empty; the rasterizer would draw nothing.

License

Apache-2.0

版本与发布记录

@basenative/og-image 共发布 11 个版本,首次发布于 2026/10/18;过去 12 个月发布了 11 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.0.0latest2026/10/312 天前
2.4.02026/10/268 天前
2.3.22026/10/2212 天前
2.3.12026/10/2212 天前
2.3.02026/10/2212 天前
2.2.02026/10/2113 天前

关于 @basenative/og-image 的常见问题

@basenative/og-image 是什么?

运行时无关OG /社交分享PNG渲染器- SVG卡场景栅格化的resvg-wasm,与CDN+ kv缓存字体;运行在Cloudflare Workers、Node、Deno和Bun上

如何安装 @basenative/og-image?

在项目目录运行 npm install @basenative/og-image,或使用 pnpm add / yarn add / bun add。

@basenative/og-image 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@basenative/og-image 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@basenative/og-image 可以商用吗?

@basenative/og-image 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@basenative/og-image 还在维护吗?

最新版本 3.0.0 发布于 2026/10/31(2 天前);至今共发布 11 个版本,其中过去 12 个月发布了 11 个;首次发布于 2026/10/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。