a link preview card drawn as SVG and rasterized at build, every crop kept
npx shadcn add @ag/ogAlready installed? Preview an update
npx shadcn add @ag/og --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-11-06
- New: a link preview card (og:image, X's
summary_large_image) drawn as one SVG from data and rasterized before the build, so a crawler fetches a static PNG:ogStillCard(the name and its line beside another item's SVG still, an editor or a terminal) andogWordmarkCard(the name alone), set for the size a reader sees the card at, inside what every crop keeps (OG_CROPS: the whole, X's 2:1, a thumbnail's square;OG_SAFE), throwing on words or a still that do not fit.lib/og-stillrasterizes it with resvg, the Geist files handed to it outright (geistFiles, from thegeistpackage) and each face measured from its file (ogFace), so a line breaks where resvg breaks it; equal inputs give equal bytes, so a site can keep the PNG and check it. Install:shadcn add @ag/og; a Next host lists@resvg/resvg-jsinserverExternalPackageswhen a server component rasterizes. - Added:
ogStackCard, the name large, a still across the card's width under it, and the line small and centred under that (OG_STACK), for a still that says more than its name: an editor's type drawn half as large again as beside the words. Each part is placed as it is seen, words from their capitals' top to their baseline, and the height left over is shared in one unit, the name's gap to the still: half of it between the still and the line, and at each end a margin the golden ratio of the gap beside it;OgFacecarries the font's cap height (cap, OS/2'ssCapHeight, read byogFace). It throws when the line's space falls underOG_STACK.least, or the line takes more than one line.
also called opengraph-image, og image, social card
shelf social & links
ogStillCard: an editor still at the right, the crops over it, then the PNG at each crop's seen size and whole
ogWordmarkCard: for a page with nothing to show but what it is
the demo above, verbatim
import { Sample } from "@/app/samples"
import { diagnoseWords } from "@/registry/base-nova/lib/code-session"
import {
OG,
OG_CROPS,
OG_SAFE,
type OgCard,
ogStillCard,
ogWordmarkCard,
} from "@/registry/base-nova/lib/og"
import {
geistFiles,
ogFace,
rasterizeOg,
} from "@/registry/base-nova/lib/og-still"
import {
editorStill,
renderEditorSvg,
} from "@/registry/base-nova/lib/terminal-editor-screen"
import { Image } from "@/registry/base-nova/ui/image"
// #region card
// What the card says, in the faces it is set in: measured from the files resvg is
// handed, and named as this page names them too, so the inlined card and the PNG are
// the same type.
const FILES = geistFiles()
const FACES = {
title: ogFace(FILES.sansSemiBold, "Geist"),
line: ogFace(FILES.sans, "Geist"),
}
const CARD: OgCard = {
title: "names",
line: [
"A list of names, typed in ",
{ text: "names.ts", ink: "#e7a13c" },
", greeted one by one.",
],
faces: FACES,
look: {
ground: "#0b0d11",
ink: "#f2f4f7",
muted: "#9aa3ad",
light: "#e7a13c",
},
}
// #endregion
// #region still
// The editor at an instant, drawn by terminal-editor's own still: a typo, said.
const TS = diagnoseWords([
{ word: "lenght", message: "no 'lenght' on string[]", source: "typescript" },
])
async function editor(): Promise<string> {
const screen = await editorStill({
files: [
{
path: "src/main.ts",
text: `import { greet, names } from "./names"\n\nfor (const name of names)\n console.log(greet(name))\n`,
},
],
steps: [
{ kind: "open", path: "src/main.ts" },
{
kind: "type",
line: 4,
text: "\nconsole.log(names.lenght)",
delay: 300,
},
],
progress: 1,
rows: 7,
columns: 44,
diagnose: TS,
accent: "#e7a13c",
})
return renderEditorSvg(screen, { fontSize: 20 })
}
// #endregion
/** The card as the page shows it: inlined, every crop drawn over it, its name at its
* corner; and the PNG resvg made of it, which is what an unfurler fetches. */
async function Card({ svg }: { svg: string }) {
const png = await rasterizeOg(svg, Object.values(FILES))
return (
<div className="space-y-6">
<div className="relative">
<div
className="[&>svg]:block [&>svg]:h-auto [&>svg]:w-full"
// biome-ignore lint/security/noDangerouslySetInnerHtml: lib/og's own SVG, its text escaped
dangerouslySetInnerHTML={{ __html: svg }}
/>
<svg
aria-hidden
viewBox={`0 0 ${OG.width} ${OG.height}`}
className="pointer-events-none absolute inset-0 h-full w-full"
>
{OG_CROPS.filter((c) => c.name !== "whole").map((c) => (
<g key={c.name}>
<rect
x={c.x}
y={c.y}
width={c.width}
height={c.height}
fill="none"
stroke="#ff4d8d"
strokeWidth={2}
strokeDasharray="10 8"
/>
<text x={c.x + 10} y={c.y + 26} fill="#ff4d8d" fontSize={18}>
{c.name}: {c.who}
</text>
</g>
))}
<rect
x={OG_SAFE.x}
y={OG_SAFE.y}
width={OG_SAFE.width}
height={OG_SAFE.height}
fill="none"
stroke="#4dd0ff"
strokeWidth={1.5}
/>
</svg>
</div>
<div className="flex flex-wrap items-start gap-6">
{OG_CROPS.map((c) => (
<figure key={c.name} className="space-y-2">
{/* Each crop at about the width a reader sees it: the type is judged here. */}
<svg
role="img"
aria-label={`the card as ${c.who} shows it`}
viewBox={`${c.x} ${c.y} ${c.width} ${c.height}`}
width={c.seen}
className="block rounded-md"
>
<image
href={`data:image/png;base64,${png.toString("base64")}`}
width={OG.width}
height={OG.height}
/>
</svg>
<figcaption className="font-mono text-muted-foreground text-xs">
{c.name} · {c.seen} px · the PNG
</figcaption>
</figure>
))}
</div>
<Image
alt="the PNG resvg made of the card, at its own size"
className="w-full rounded-md"
src={`data:image/png;base64,${png.toString("base64")}`}
/>
</div>
)
}
export default async function Demo() {
const still = await editor()
return (
<div className="space-y-16">
<Sample
name="still"
title="the thing beside its name"
description="ogStillCard: an editor still at the right, the crops over it, then the PNG at each crop's seen size and whole"
with="card still"
>
<Card svg={ogStillCard({ ...CARD, still })} />
</Sample>
<Sample
name="wordmark"
title="the name alone"
description="ogWordmarkCard: for a page with nothing to show but what it is"
with="card"
>
<Card svg={ogWordmarkCard(CARD)} />
</Sample>
</div>
)
}the source, every file an install writes
// LINK PREVIEWS: the card an unfurler shows for a page (Open Graph's `og:image`, X's
// `summary_large_image`), drawn as one SVG from data and rasterized before the build
// (lib/og-still), so a crawler fetches a static file and no function runs for it.
// Framework-free by contract: no React, no DOM, no Node; what reads files is og-still.
//
// const card = ogStillCard({ title, line, still, faces, look }) // an SVG string
// const png = await rasterizeOg(card, files) // lib/og-still
//
// Designed once against every crop (`OG_CROPS`): whatever the card says sits inside
// `OG_SAFE`, the part no unfurler cuts, and the type is set for the size a reader sees
// the card at, a third of it or less, never for the 1200 px it is drawn at. A still on
// the card is another item's SVG still (terminal-editor-screen `renderEditorSvg`,
// terminal-session `renderSessionSvg`), drawn in: one drawing, never a second one here.
// A quote's card is quote-card's `renderQuoteSvg` alone, not a layout of this file.
/** The card, px: the size every unfurler asks for (1.91:1). */
export const OG = { width: 1200, height: 630 } as const
/** A part of the card an unfurler shows, and about how wide a reader sees it, px. */
export interface OgCrop {
name: string
/** Who shows it so. */
who: string
x: number
y: number
width: number
height: number
/** About how wide it is shown, px: what the type is set for. */
seen: number
}
/** What each unfurler keeps of the card: the whole of it in a large preview, X's 2:1
* cut from its middle, and a centred square where a preview is a thumbnail beside the
* words (WhatsApp; Telegram, iMessage and Slack when they show it small). */
export const OG_CROPS: readonly OgCrop[] = [
{
name: "whole",
who: "Telegram, Slack, Discord, iMessage, LinkedIn, Facebook",
x: 0,
y: 0,
width: OG.width,
height: OG.height,
seen: 400,
},
{
name: "2:1",
who: "X, summary_large_image",
x: 0,
y: (OG.height - OG.width / 2) / 2,
width: OG.width,
height: OG.width / 2,
seen: 506,
},
{
name: "square",
who: "a thumbnail beside the words: WhatsApp, and the small previews",
x: (OG.width - OG.height) / 2,
y: 0,
width: OG.height,
height: OG.height,
seen: 96,
},
]
/** The card's margin, px: a tenth of its height on every side. */
export const OG_PAD = OG.height / 10
/** What every crop but the square keeps, inside the margin: where the words go. */
export const OG_SAFE = {
x: OG_PAD,
y: (OG.height - OG.width / 2) / 2 + OG_PAD,
width: OG.width - 2 * OG_PAD,
height: OG.width / 2 - 2 * OG_PAD,
} as const
/** A face the card sets words in, as its file measures it: `width` of a text in em
* (lib/og-still `ogFace` reads it from the font), so a line breaks where the
* rasterizer will break it, never at a guessed average. */
export interface OgFace {
/** The family the SVG names, as the rasterizer is handed its file. */
family: string
weight: number
ascent: number
descent: number
/** The capitals' height above the baseline, em: where words are seen to begin, which
* a line's ascent overshoots. */
cap: number
width: (text: string) => number
}
/** The card's colours: its ground, the words, the quieter words. */
export interface OgLook {
ground: string
ink: string
muted: string
/** A soft light from the still's side, over the ground. None when unset. */
light?: string
}
/** A line of words, some in their own ink (a product's extension lit in its colour). */
export type OgWords = readonly (string | { text: string; ink: string })[]
/** Text as SVG markup takes it, in an element or an attribute's quotes. */
const xmlText = (s: string) =>
s
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
/** `words` broken into lines no wider than `width` px at `size`, each line a list of
* pieces in one ink. A single word wider than the line screams: shrink the type or
* say it shorter, never a word cut. */
export function wrapWords(
words: OgWords,
face: OgFace,
size: number,
width: number,
): { text: string; ink: string | null }[][] {
const tokens = words.flatMap((w) => {
const { text, ink } = typeof w === "string" ? { text: w, ink: null } : w
return text
.split(/(\s+)/)
.filter(Boolean)
.map((t) => ({ text: t, ink }))
})
const lines: { text: string; ink: string | null }[][] = [[]]
let used = 0
for (const t of tokens) {
const line = lines.at(-1) as { text: string; ink: string | null }[]
if (/^\s+$/.test(t.text)) {
if (line.length) {
line.push({ text: " ", ink: t.ink })
used += face.width(" ") * size
}
continue
}
const w = face.width(t.text) * size
if (w > width)
throw new Error(
`og: "${t.text}" is ${Math.round(w)} px at ${size} px, wider than the ${Math.round(width)} px line`,
)
// A word that does not fit starts the next line; the space it followed goes.
if (used + w > width && line.length) {
if (line.at(-1)?.text === " ") line.pop()
lines.push([t])
used = w
} else {
line.push(t)
used += w
}
}
return lines.map((l) => {
const merged: { text: string; ink: string | null }[] = []
for (const p of l) {
const last = merged.at(-1)
if (last && last.ink === p.ink) last.text += p.text
else merged.push({ ...p })
}
return merged
})
}
/** Lines of words as SVG `<text>`, the first baseline at `y`. */
function wordsSvg(
lines: { text: string; ink: string | null }[][],
{
x,
y,
size,
leading,
face,
ink,
}: {
x: number
y: number
size: number
leading: number
face: OgFace
ink: string
},
): string {
return lines
.map(
(line, i) =>
`<text x="${x}" y="${y + i * size * leading}" font-size="${size}" font-weight="${face.weight}" fill="${ink}" style="font-family: ${xmlText(face.family)}">${line
.map((p) =>
p.ink
? `<tspan fill="${p.ink}">${xmlText(p.text)}</tspan>`
: xmlText(p.text),
)
.join("")}</text>`,
)
.join("\n ")
}
/** A still's root and its `viewBox`: what keeps its shape when it is scaled. */
function rootOf(svg: string): { box: string; ratio: number } {
const open = /^<svg\b([^>]*)>/.exec(svg.trim())
if (!open) throw new Error("og: a still is an <svg> element")
const box = /viewBox="([\d.\s-]+)"/.exec(open[1] as string)?.[1]
if (!box) throw new Error("og: a still without a viewBox cannot be scaled")
const [, , w, h] = box.split(/\s+/).map(Number) as number[]
return { box, ratio: (h as number) / (w as number) }
}
/** A still drawn inside the card at `x`, `y`, `width` wide, its root's size replaced. */
function placed(svg: string, x: number, y: number, width: number): string {
const { box, ratio } = rootOf(svg)
return svg
.trim()
.replace(
/^<svg\b[^>]*>/,
`<svg x="${x}" y="${y}" width="${width}" height="${width * ratio}" viewBox="${box}">`,
)
}
export interface OgCard {
/** The name, large. */
title: string
/** What it is, under the name. */
line: OgWords
faces: { title: OgFace; line: OgFace }
look: OgLook
}
/** The type, px, for a reader who sees the card a third of its size: the name a
* headline (about 35 px seen), the line a sentence (about 13 px seen). */
export const OG_TYPE = { title: 104, line: 38, leading: 1.25 } as const
/** The card's ground, and its light when it has one. */
function ground(look: OgLook, from: { x: number; y: number }): string {
const light = look.light
? `<defs><radialGradient id="og-light" cx="${from.x / OG.width}" cy="${from.y / OG.height}" r="0.75"><stop offset="0" stop-color="${look.light}" stop-opacity="0.22"/><stop offset="1" stop-color="${look.light}" stop-opacity="0"/></radialGradient></defs>
<rect width="${OG.width}" height="${OG.height}" fill="url(#og-light)"/>`
: ""
return `<rect width="${OG.width}" height="${OG.height}" fill="${look.ground}"/>
${light}`
}
/** The name and the line as a block `width` wide at `x`, centred on the card's
* height: the lines and the block's height. */
function words(card: OgCard, x: number, width: number): string {
const { title, line } = card.faces
const titleLines = wrapWords([card.title], title, OG_TYPE.title, width)
const lineLines = wrapWords(card.line, line, OG_TYPE.line, width)
const titleH = OG_TYPE.title * (title.ascent + title.descent)
const gap = OG_TYPE.line * 0.9
const linesH = lineLines.length * OG_TYPE.line * OG_TYPE.leading
const top = (OG.height - (titleLines.length * titleH + gap + linesH)) / 2
if (top < OG_SAFE.y)
throw new Error(
`og: the words are ${Math.round(OG.height - 2 * top)} px tall, more than the ${OG_SAFE.height} px every crop keeps`,
)
return `${wordsSvg(titleLines, {
x,
y: top + OG_TYPE.title * title.ascent,
size: OG_TYPE.title,
leading: title.ascent + title.descent,
face: title,
ink: card.look.ink,
})}
${wordsSvg(lineLines, {
x,
y:
top +
titleLines.length * titleH +
gap +
(OG_TYPE.line * (OG_TYPE.leading - (line.ascent + line.descent))) / 2 +
OG_TYPE.line * line.ascent,
size: OG_TYPE.line,
leading: OG_TYPE.leading,
face: line,
ink: card.look.muted,
})}`
}
const svgOf = (body: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${OG.width} ${OG.height}" width="${OG.width}" height="${OG.height}">
${body}
</svg>
`
/** The name and the line alone, on the ground: a page with nothing to show but
* what it is. */
export function ogWordmarkCard(card: OgCard): string {
return svgOf(
`${ground(card.look, { x: OG.width, y: 0 })}
${words(card, OG_SAFE.x, OG_SAFE.width * 0.75)}`,
)
}
/** A stacked card's type, px: the name large over the still, the line small under it.
* Its spaces in one unit, the name's gap to the still: the line's gap to the still a
* share of it (`lineShare`), so the line only breathes; and at each end the margin the
* golden ratio of the gap beside it (`margin`), so the name and the line each sit
* nearer the still than the card's edge. The least the line's gap may be keeps X's 2:1
* crop (15 px off each end) from taking its margin. */
export const OG_STACK = {
title: 92,
line: 24,
margin: (1 + Math.sqrt(5)) / 2,
lineShare: 0.5,
least: 20,
} as const
/** The name, a still across the card's width (an editor, a terminal) under it, and the
* line small under that, centred: the thing itself as large as the card allows, what
* it is called above it. Each part is placed as it is seen, words from their capitals'
* top to their baseline (a line box's ascent and descent are empty space no reader
* sees), and the height left over is shared in `OG_STACK`'s proportions: the margin
* above the name, its gap to the still, the line's gap to the still, the margin below
* it. A line's gap under `OG_STACK.least`, or a line that will not fit on one line,
* screams. */
export function ogStackCard(card: OgCard & { still: string }): string {
const width = OG.width - 2 * OG_PAD
const height = width * rootOf(card.still).ratio
const { title, line } = card.faces
const lineLines = wrapWords(card.line, line, OG_STACK.line, width)
if (lineLines.length > 1)
throw new Error(
`og: the line takes ${lineLines.length} lines in ${width} px: say it shorter`,
)
const titleCap = OG_STACK.title * title.cap
const lineCap = OG_STACK.line * line.cap
// The height left over in units of the name's gap: the margin above the name and
// the gap itself, then the line's gap and the margin below it, the same proportion.
const gap =
(OG.height - (titleCap + height + lineCap)) /
((1 + OG_STACK.margin) * (1 + OG_STACK.lineShare))
const lineGap = gap * OG_STACK.lineShare
if (lineGap < OG_STACK.least)
throw new Error(
`og: the line's gap is ${Math.round(lineGap)} px, less than ${OG_STACK.least}: give the still fewer rows or more columns`,
)
const titleBase = gap * OG_STACK.margin + titleCap
const stillTop = titleBase + gap
const lineBase = stillTop + height + lineGap + lineCap
const lineW = (lineLines[0] ?? []).reduce(
(w, piece) => w + line.width(piece.text) * OG_STACK.line,
0,
)
return svgOf(
`${ground(card.look, { x: OG.width / 2, y: stillTop + height / 2 })}
${wordsSvg(
wrapWords(
[card.title],
title,
OG_STACK.title,
title.width(card.title) * OG_STACK.title + 1,
),
{
x: OG_PAD,
y: titleBase,
size: OG_STACK.title,
leading: title.ascent + title.descent,
face: title,
ink: card.look.ink,
},
)}
${placed(card.still, OG_PAD, stillTop, width)}
${wordsSvg(lineLines, {
x: (OG.width - lineW) / 2,
y: lineBase,
size: OG_STACK.line,
leading: OG_TYPE.leading,
face: line,
ink: card.look.muted,
})}`,
)
}
/** The still's share of the card's width, and the gap between it and the words. */
export const OG_STILL = { share: 0.52, gap: 48 } as const
/** The name and the line at the left, a still at the right (an editor, a terminal):
* the thing itself beside what it is called. The still is centred on the card's
* height and screams when it is taller than what every crop keeps. */
export function ogStillCard(card: OgCard & { still: string }): string {
const width = OG.width * OG_STILL.share
const height = width * rootOf(card.still).ratio
if (height > OG_SAFE.height + 2 * OG_PAD)
throw new Error(
`og: the still is ${Math.round(height)} px tall at ${Math.round(width)} px wide, more than the ${OG.width / 2} px X keeps: give it fewer rows`,
)
const x = OG.width - OG_PAD - width
const y = (OG.height - height) / 2
return svgOf(
`${ground(card.look, { x: x + width / 2, y: y + height / 2 })}
${words(card, OG_PAD, x - OG_STILL.gap - OG_PAD)}
${placed(card.still, x, y, width)}`,
)
}// THE CARD TO A PICTURE, at build: lib/og's SVG rasterized by resvg with the font
// FILES handed to it outright, so the type is the face the card names and never
// whatever a build machine's fontconfig holds; and each face measured from its file
// (`ogFace`), so lib/og breaks a line where resvg will. Node only: a build script, a
// prepare step or a server demo reads it, never a browser.
//
// const files = geistFiles()
// const faces = { title: ogFace(files.sansSemiBold, "Geist"), line: ogFace(files.sans, "Geist") }
// const png = await rasterizeOg(ogStillCard({ …, faces }), Object.values(files))
//
// Equal inputs give equal bytes (resvg is deterministic), so a site can keep the PNG
// in its tree and its check can draw it again and compare.
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { renderAsync } from "@resvg/resvg-js"
import { OG, type OgFace } from "@/registry/base-nova/lib/og"
/** The Geist files the card and its stills are set in, from the `geist` package (the
* faces next/font serves the same sites): the sans for the words, the mono for an
* editor or a terminal still. Read from the project the build runs in, by path, so no
* bundler follows it into a page. */
export function geistFiles() {
const root = join(
/*turbopackIgnore: true*/ process.cwd(),
"node_modules/geist/dist/fonts",
)
const files = {
sans: join(root, "geist-sans/Geist-Regular.ttf"),
sansSemiBold: join(root, "geist-sans/Geist-SemiBold.ttf"),
mono: join(root, "geist-mono/GeistMono-Regular.ttf"),
monoBold: join(root, "geist-mono/GeistMono-Bold.ttf"),
}
for (const f of Object.values(files))
if (!existsSync(f))
throw new Error(
`og-still: ${f} is missing: install geist in ${process.cwd()}`,
)
return files
}
/** A TrueType file's tables, by tag. */
function tables(font: Buffer): Map<string, DataView> {
const view = new DataView(font.buffer, font.byteOffset, font.byteLength)
const count = view.getUint16(4)
const out = new Map<string, DataView>()
for (let i = 0; i < count; i++) {
const at = 12 + i * 16
const tag = font.toString("latin1", at, at + 4)
const offset = view.getUint32(at + 8)
const length = view.getUint32(at + 12)
out.set(tag, new DataView(font.buffer, font.byteOffset + offset, length))
}
return out
}
/** A face as its file measures it: each character's advance (cmap format 4 to hmtx),
* its ascent and descent (hhea), its weight (OS/2), all in em. Kerning is not read:
* a line measured here runs a hair long, so it breaks early rather than late. */
export function ogFace(file: string, family: string): OgFace {
const t = tables(readFileSync(file))
const need = (tag: string) => {
const v = t.get(tag)
if (!v) throw new Error(`og-still: ${file} has no ${tag} table`)
return v
}
const head = need("head")
const hhea = need("hhea")
const hmtx = need("hmtx")
const os2 = need("OS/2")
const cmap = need("cmap")
const em = head.getUint16(18)
const metrics = hhea.getUint16(34)
const advance = (glyph: number) =>
hmtx.getUint16(4 * Math.min(glyph, metrics - 1)) / em
// The Unicode BMP subtable (platform 3 encoding 1, or platform 0), format 4.
let sub: DataView | null = null
for (let i = 0; i < cmap.getUint16(2); i++) {
const at = 4 + i * 8
const platform = cmap.getUint16(at)
const encoding = cmap.getUint16(at + 2)
const offset = cmap.getUint32(at + 4)
if (
((platform === 3 && encoding === 1) || platform === 0) &&
cmap.getUint16(offset) === 4
) {
sub = new DataView(cmap.buffer, cmap.byteOffset + offset)
break
}
}
if (!sub) throw new Error(`og-still: ${file} has no format 4 cmap`)
const s = sub
const segs = s.getUint16(6) / 2
const ends = 14
const starts = ends + segs * 2 + 2
const deltas = starts + segs * 2
const ranges = deltas + segs * 2
const glyphOf = (c: number) => {
for (let i = 0; i < segs; i++) {
if (c > s.getUint16(ends + i * 2)) continue
const start = s.getUint16(starts + i * 2)
if (c < start) return 0
const delta = s.getInt16(deltas + i * 2)
const range = s.getUint16(ranges + i * 2)
if (range === 0) return (c + delta) & 0xffff
const g = s.getUint16(ranges + i * 2 + range + 2 * (c - start))
return g === 0 ? 0 : (g + delta) & 0xffff
}
return 0
}
const widths = new Map<number, number>()
const of = (c: number) => {
let w = widths.get(c)
if (w === undefined) {
const g = glyphOf(c)
if (g === 0)
throw new Error(
`og-still: ${family} (${file}) has no glyph for U+${c.toString(16).toUpperCase()}`,
)
w = advance(g)
widths.set(c, w)
}
return w
}
// sCapHeight is OS/2's from version 2 on.
if (os2.getUint16(0) < 2)
throw new Error(
`og-still: ${file}'s OS/2 table is version ${os2.getUint16(0)}, before it gave the capitals' height`,
)
return {
family,
weight: os2.getUint16(4),
ascent: hhea.getInt16(4) / em,
descent: -hhea.getInt16(6) / em,
cap: os2.getInt16(88) / em,
width: (text) => {
let w = 0
for (const ch of text) w += of(ch.codePointAt(0) as number)
return w
},
}
}
/** The card as a PNG at `width` px (the card's own by default), set in `fonts` and
* nothing else: a family the SVG names that no file holds falls to the first file,
* never to the system's. */
export async function rasterizeOg(
svg: string,
fonts: readonly string[],
{ width = OG.width }: { width?: number } = {},
): Promise<Buffer> {
if (fonts.length === 0)
throw new Error("og-still: no font files to set the card in")
const image = await renderAsync(svg, {
fitTo: { mode: "width", value: width },
font: { loadSystemFonts: false, fontFiles: [...fonts] },
})
return Buffer.from(image.asPng())
}