- All posts
- Drawing link previews that work in Arabic
Drawing link previews that work in Arabic
How to produce one Open Graph image per route instead of a single shared image. Two working setups — HarfBuzz and resvg at build time, satori per request — with the meta tags, font loading, and what to do when rendering fails. Includes what changes for right-to-left text: word order, letter joining, and the letters a font does not have.
A link preview is a PNG. You put a URL in og:image, a crawler fetches it
and caches it, and every platform that shows your link — Slack, X,
LinkedIn, WhatsApp, iMessage, Telegram — draws that file. Nothing about the
page is consulted at display time, and no browser of yours ever renders it.
Most sites ship one image for every URL. It tells a reader which site the link belongs to, which the domain already did. A per-route card can carry the page’s own title and description instead.
I have two of these in production, built opposite ways. omaralbeik.com draws its cards at build time in plain Node. unblocksyria.com draws them per request at the edge. Both are bilingual, and that is where most of the work went. Arabic is where a renderer’s shortcuts stop working.
The short version
If you only need the working checklist, it is this. Everything after it is the explanation, and the part you will want when one of these bites.
- One card per route,
og:imageabsolute, with width, height, alt andtwitter:card. - Build time unless the card’s content can change after deploy.
- Load fonts as bytes you control, and know which container your renderer can read.
- Decide the order of preference for what gets dropped before you need it.
- Every path has to end in a picture; a 500 means no image at all, not a default one.
- For right-to-left: order, face selection and measurement are three separate problems. Pure Arabic will look fine while all three are broken.
- Verify the ordering mechanically. You cannot see it.
The tags
<meta property="og:image" content="https://omaralbeik.com/og/en/blog/some-post.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Some post — Omar Albeik" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://omaralbeik.com/og/en/blog/some-post.png" />
Seven tags. The URL must be absolute; crawlers do not resolve a relative one.
og:image:type saves a sniff. Width and height let a client reserve the space,
so the card does not reflow as it loads. og:image:alt is what a screen reader
announces. Without twitter:card set to summary_large_image you get a small
square thumbnail instead of the card you drew. And twitter:image only repeats
og:image, which X falls back to when it is absent — I would rather state it
than depend on that.
1200×630 is what everything accepts. Platforms crop it differently — X to 16:9 in my testing — so keep the text inside a margin. Mine is 96px, arrived at by looking at crops. Nobody publishes a safe area, only an image size. Verify yours rather than copying mine.
That is the image half of the head. og:title, og:description, og:url and
og:type still have to be there.
Build time or per request
One question decides it: can the card’s content change after the deploy that produced it?
If it cannot, draw at build time. Nothing sits in the request path, there is no runtime failure mode, and the renderer can be any program you like.
If it can, draw per request. On unblocksyria.com a service card carries the blocking status and the outreach progress beside it, and both change. A build-time image would be a picture of the catalogue on deploy day, which for a service that has since been unblocked is not stale so much as wrong.
Build time, in plain Node
The renderer runs after the build, as an ordinary Node script:
"build": "astro build && node scripts/og-render.mjs"
The script itself is the whole of it — read the built pages, draw one card each, write it where the page said it would be:
for (const card of await scanCards("dist/client")) {
const png = await cardPng(card);
const file = join("dist/client/og", `${card.path.replace(/^\//, "")}.png`);
await mkdir(dirname(file), { recursive: true });
await writeFile(file, png);
}
scanCards pulls the title, description and kicker out of each page’s own
<head> — the kicker and the date ride along as two extra meta tags the page
already emits. That is one fewer list to keep in step, and a card cannot
disagree with the page it previews.
Outside the framework on purpose. With the Cloudflare adapter, Astro
prerenders routes inside workerd. There is no filesystem there to load a font
from, and no way to load a native rasterizer. An og.png.ts endpoint fails at
build either on an unbundleable .node binary or on a missing file, depending
which rasterizer it reaches for. Run afterwards in Node and the same resvg that
draws the site’s icons draws the cards.
Per request, at the edge
ImageResponse from next/og: satori for layout, a rasterizer behind it.
The route is a file that default-exports an image:
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }) {
const { locale, slug } = await params;
const service = await getService(slug, locale);
return renderCard(<ServiceCard service={service} locale={locale} />);
}
Fonts are the awkward part. There is no filesystem on Workers, and
import.meta.url does not resolve to a readable file. Fetching them from your
own origin means a subrequest back through the edge on every crawler hit, with
a self-fetch loop as the failure mode. So the faces are base64 in the bundle.
That is not free. Two weights each of a Latin and an Arabic face are 129KB of font binary, 172KB after base64, in every bundle. I would rather pay that at deploy than add a network hop to the one path with no fallback. Subset the faces to the glyphs your cards draw and it gets much smaller.
WOFF, not WOFF2 — satori cannot read WOFF2, and it fails by throwing rather than by falling back to a default face.
const QOMRA_REGULAR_B64 = "d09GRgABAAAAAG10...";
export const qomraRegular = Uint8Array.from(
atob(QOMRA_REGULAR_B64),
(c) => c.charCodeAt(0),
).buffer;
Make every path end in a picture
A crawler that gets a 500 for og:image does not fall back to your site
default. It renders the card with no image at all.
That is worth designing around, because the obvious code puts the failure somewhere you cannot catch it:
// Don't: the try/catch has already exited by the time this fails.
export default async function Image({ params }) {
try {
return new ImageResponse(<ServiceCard data={data} />, { width, height, fonts });
} catch {
return new ImageResponse(<BrandCard />, { width, height, fonts });
}
}
arrayBuffer() is where the rasterizing is actually forced. Call it before you
return and the failure lands back inside the try:
export function renderCard(element: ReactElement): Promise<ArrayBuffer> {
return new ImageResponse(element, { width: OG_WIDTH, height: OG_HEIGHT, fonts })
.arrayBuffer();
}
The caller then has somewhere to fall back to:
let png: ArrayBuffer;
try {
png = await renderCard(<ServiceCard data={data} logo={logo} />);
} catch {
try {
// A remote logo is the most likely thing to have failed.
png = await renderCard(<ServiceCard data={data} logo={null} />);
} catch {
// Draws no page data at all, so no page can break it.
png = await renderCard(<BrandCard locale={locale} />);
}
}
return new Response(png, { headers: { "content-type": "image/png" } });
Cache the two failures differently. A slug that does not exist is a permanent answer and caches like any other card. A 5xx from your own API is not. Cache the fallback for a transient failure and you pin the generic card onto a real page’s link for the whole stale-while-revalidate window.
Build-time rendering has the same bug elsewhere. A route rendered per request
has no built HTML to read, so it advertises a card that was never drawn. Two
checks at the end of the render script cover it: every advertised card exists
on disk, and every prerender = false page either opts out or is noindex.
Two ways the URL itself goes wrong
Both of these cost me a round of crawler traffic, and neither is visible in anything you render.
The image URL is usually derived from the page’s canonical path by appending
/opengraph-image. On a paginated list that path carries the query, so
/articles?page=2 produced /en/articles?page=2/opengraph-image — where the
suffix is part of the query value, and the URL resolves to the HTML page.
Every crawler past page one of that list was handed a document where it
expected a PNG. Drop the query: there is no card per page of a list.
// Don't — on /articles?page=2 this yields
// /en/articles?page=2/opengraph-image
const image = new URL(`${canonicalPath}/opengraph-image`, SITE_URL);
// Do — the card belongs to the path, never to the query.
const image = new URL(canonicalPath, SITE_URL);
image.search = "";
image.pathname = `${image.pathname.replace(/\/$/, "")}/opengraph-image`;
The second is a name collision. Reserve opengraph-image as a slug. A
framework that resolves a static segment before the dynamic one beside it will
let a row slugged that shadow its own collection’s card, and the detail page
answers with a picture instead of itself.
Fitting the text
Wrapping needs real advances, not an estimate. Given a measure function the rest is ordinary: greedy wrap to a line limit, an ellipsis when text is left over, and a mid-word break for a token wider than the column, since a URL has no spaces to break at.
Estimates fail badly here. Sizing a title by its character count is off by about a factor of two between these two scripts. On the Unblock Syria cards it set “المرصد الأورومتوسطي لرصد الزلازل” at 28px beside an “EMSC” at 104px. Both are ordinary names for the same kind of organisation.
Satori has no line clamp either. A four-line sentence grows the block into whatever sits under it. The clamp comes from the same metrics as everything else.
Then there is the case where nothing fits. A three-line title cannot also carry two lines of description at full size; the block runs over the rule above and the mark below. Step the type down instead. Decide the order of preference before you need it: keep the whole description, then the unabbreviated title, then the large type.
for (const descLines of card.description ? [2, 1, 0] : [0]) {
for (const size of [64, 57, 50]) {
const block = layout(card, { size, descLines });
if (block.height <= BAND) return block; // first fit wins
}
}
The loop order is the policy. Description on the outside means a card drops its second description line before it drops a type size. That is deliberate: the description is the only part that says something the title does not.
Right to left
This is usually described as one problem, “RTL support”. It is three, they fail independently, and they have three different fixes.
Word order
resvg does not implement the Unicode Bidirectional Algorithm. direction and
unicode-bidi are ignored entirely: setting embed, isolate or plaintext
produces byte-identical output, so there is nothing to configure your way out
of. Satori has the same gap from the other side — it places words in logical
order, left to right, so every Arabic name comes out backwards.
Pure Arabic looks right anyway, which is what makes this expensive to catch. HarfBuzz infers direction from the script. A line with nothing Latin in it renders correctly, and the bug appears only once a product name or a version number joins the sentence. On a site about software, that is most of them.
At build time, do the ordering yourself. bidi-js resolves visual order — rule L2 of the algorithm: from the highest embedding level down to the lowest odd one, reverse every contiguous stretch of runs at or above it. Break lines in logical order, then reorder each line separately. That is what a browser does.
Split into runs first. A run is the largest stretch you can hand a shaper as a unit: one direction, one font.
const bidi = bidiFactory();
function logicalRuns(text, stack, baseDir) {
const { levels } = bidi.getEmbeddingLevels(text, baseDir);
const runs = [];
for (let i = 0; i < text.length; ) {
const char = String.fromCodePoint(text.codePointAt(i));
const level = levels[i];
const face = faceFor(stack, text.codePointAt(i));
const last = runs[runs.length - 1];
if (last && last.level === level && last.face === face) last.text += char;
else runs.push({ level, face, text: char });
i += char.length;
}
return runs;
}
Then rule L2 itself, which is shorter than its description:
function reorder(runs) {
const levels = runs.map((r) => r.level);
const highest = Math.max(...levels);
const odd = levels.filter((l) => l % 2 === 1);
const lowestOdd = odd.length ? Math.min(...odd) : highest + 1;
const out = [...runs];
for (let level = highest; level >= lowestOdd; level--) {
for (let start = 0; start < out.length; start++) {
if (out[start].level < level) continue;
let end = start;
while (end + 1 < out.length && out[end + 1].level >= level) end++;
out.splice(start, end - start + 1, ...out.slice(start, end + 1).reverse());
start = end;
}
}
return out;
}
Shaping a run is six lines, and two of them are traps:
function shapeRun(run) {
const buffer = new HbBuffer();
buffer.addText(run.text);
buffer.guessSegmentProperties();
buffer.setDirection(run.level % 2 === 1 ? Direction.RTL : Direction.LTR);
shape(run.face.font, buffer);
return buffer.getGlyphInfosAndPositions();
}
Guess first: that sets the script and the language. Then state the direction,
or the shaper infers it from the characters and undoes the bidi pass you just
ran. And the direction is a numeric enum. Passing the string "rtl" is not a
type error — it means INVALID, and every advance comes back zero.
At the edge, where you cannot run HarfBuzz yourself, row-reverse on a flex
row gets you the same result. One caveat. Reorder directional runs, not whitespace-delimited words:
bidi never reverses the order inside a Latin run. Split on spaces and reverse
the tokens and “متجر Google Play” becomes “Play Google” — wrong in the one
place a mixed name actually occurs.
Which face draws the glyph
The empty boxes in that figure are the second failure. It is not really about Arabic. It is about font fallback in a renderer that has none.
Fontsource splits a family into one file per script, and you load files, not families. The Arabic file of IBM Plex Sans Arabic cannot draw the letters in “libVLC”. Its Latin sibling is a separate download from the same family. Satori is stricter still: it picks a face per glyph and does not fall back when that face lacks one. An Arabic label against a Latin-only font set throws, and the route returns a 500.
At build time, treat fonts as ordered stacks and pick per run. Ask each face what it covers rather than trusting its name:
export function loadFace(sfnt) {
const face = new Face(new Blob(sfnt), 0);
const font = new Font(face);
font.setScale(face.upem, face.upem);
return { font, upem: face.upem, covers: new Set(face.collectUnicodes()) };
}
const faceFor = (stack, codePoint) =>
stack.find((face) => face.covers.has(codePoint)) ?? stack[0];
collectUnicodes reads the cmap, so this is what the file can draw. Put the
Arabic face first and its Latin companion behind it, and a Latin word inside an
Arabic sentence keeps the same typeface instead of changing voice mid-line.
At the edge, fall back for the whole string rather than the missing letters. Satori substitutes per glyph, and a name with three letters in another typeface reads as a fault rather than a choice. That rule also decides the Kurdish card: the Arabic face has no Ç, ç, ê, î, û or ş, which is most Kurdish words, so it is set in the Latin face throughout.
Joining and measurement
Arabic is cursive: a letter’s shape depends on its neighbours, and the joined forms are narrower than the isolated ones.
Satori picks the right forms. It gets the measurement wrong. A word’s box is sized from the isolated advances and drawn with the joined ones, so every box carries the difference as dead space at its end.
Across the Unblock Syria catalogue the median word loses 0.91em and the worst loses 3.49em. On a 52px title that is a 47px gap and a 181px one. The second is about a fifth of the text column, opening up in the middle of a name.
Seventeen words go the other way and are drawn wider than their box, because a few letters have a final form broader than the isolated one. An error that changes sign cannot be cancelled with a constant. So compute the width per word from the font’s metrics and set it on the box. Generate the metrics table from the embedded font, and have a test re-derive it from the same bytes, or a font swap will leave a stale table behind.
One wrong fix deserves a name, because it looks right and then fails quietly. Pre-shaping means replacing each letter with the presentation form its position calls for, then handing the renderer the result in visual order. The renderer orders it a second time, and the forms no longer match the positions. التجاري came out التجاير.
Look at what happened. The last two letters swapped, and they are exactly the two a non-joining letter had separated. Rāʾ does not join forward, so التجاري draws as four pieces — ا, لتجا, ر, ي — the last two being single letters side by side. Reorder the pieces and ي lands before ر.
Then the damage hides itself. Yāʾ does join forward, so the swapped pair connects into ير and the word comes out as a well-formed Arabic shape. Nothing throws. Every glyph is a real letter. The joins all look right. Only someone who reads the script will notice the word is now a different one.
The neutrals are the same problem one level down. A digit, a colon or a bracket attached to an Arabic word is not an Arabic character. A renderer with no bidi draws it in logical order, on the wrong side of the word: the colon belongs to the left of ليجندز, not the right. Brackets also have to be mirrored in an RTL context. Unicode specifies that; satori does not do it, so “جو (جولانج)” came out with its brackets pointing outward.
None of this arises at build time. HarfBuzz shapes each run, every glyph goes
out as a <path>, and the rasterizer fills vectors without running its text
engine. That removes its font-fallback bug too.
// A right-to-left line starts at the right edge, so the pen begins a full
// line-width left of it. Get this wrong and correct Arabic lands in the
// wrong half of the card.
let pen = dir === "rtl" ? x - measure(text, { stack, size }) : x;
for (const run of reorder(logicalRuns(text, stack, dir))) {
const scale = size / run.face.upem;
for (const glyph of shapeRun(run)) {
const d = run.face.font.glyphToPath(glyph.codepoint);
if (d) {
const gx = pen + glyph.xOffset * scale;
const gy = y - glyph.yOffset * scale;
// Flipped in y: font units go up, SVG goes down.
parts.push(
`<path d="${d}" fill="${fill}" ` +
`transform="translate(${gx} ${gy}) scale(${scale} ${-scale})"/>`,
);
}
pen += glyph.xAdvance * scale;
}
}
Setting Arabic, once it draws correctly
The last part is not a bug, but the card is wrong without it. Tracking and capitals are Latin devices. Arabic is joined, so letter-spacing severs the joins, and there are no capitals to raise. The Arabic kicker is simply set, at a size that balances the English one.
Arabic also sets smaller and looser: 58/52/46 against 64/57/50 for the title, leading 1.52 against 1.22. Its ascenders and descenders reach further, and lines set at Latin leading collide.
Mistakes that are easy to make
The ordinary ones first, none of which are about text at all:
- Meta tags injected on the client. Crawlers do not run your JavaScript. If a framework helper adds the tags after hydration, the crawler sees an empty shell. They belong in the HTML the server sends.
- A relative
og:image. The most common one. Some unfurlers resolve it, some show no image. - WebP or AVIF. Support across unfurlers is uneven in a way it is not in browsers. Use PNG or JPEG.
- Two sets of tags. A CMS and a theme each emitting an
og:imageis a coin flip over which wins. - Bot protection. The crawler is a bot, and a default challenge rule blocks it. If the page renders for you but the preview is empty, fetch the image URL with the platform’s user agent first.
- A cache, not a bug. A preview that was right and now is not is almost always cached. Re-scrape from the platform’s debugger instead of redeploying.
Then the Arabic ones. Most of these are the sections above compressed into a line each, gathered here because they share a property worth stating on its own: if you do not read the script, the output of every one of them looks fine.
- Reversing the string. Reversing code points is not right-to-left rendering. It puts visual order in a field meant to hold logical order, so search, copy-paste and screen readers break. And anything downstream that orders text will reverse it back.
- Writing presentation forms into your data. The U+FE70–FEFF block is there for round-trip compatibility with legacy encodings — Unicode says so on the chart. Used as a shaping fix it is the pre-shaping trap above.
- Treating
text-align: rightas RTL support. Alignment is not ordering. Ordering is not shaping. Fixing one does nothing for the other two. - Tracking, uppercase, small caps. Latin devices, all three. Letter-spacing severs the joins and turns a word into a row of letters.
- Testing with Arabic alone. The one that gets everybody. A line with no Latin, no digits and no punctuation renders correctly under almost every broken implementation, because the shaper infers direction from the script. Put a product name and a version number in your test string.
The first four, as code:
// Don't: none of these is right-to-left support.
const visual = [...name].reverse().join(""); // breaks search and copy
const shaped = toPresentationForms(name); // legacy compatibility block
element.style.textAlign = "right"; // alignment, not ordering
element.style.letterSpacing = "0.08em"; // severs the joins
// Do: keep the string in logical order and let the layer that draws it
// resolve direction, shaping and face — then check the result.
const runs = reorder(logicalRuns(name, stack, "rtl"));
And the test string, the cheapest fix here by far:
// Renders correctly under almost every broken implementation.
const bad = "ربط المكتبة مباشرة";
// Exercises ordering, face selection and neutrals at once.
const good = "ربط libVLC مباشرةً من Swift 6 — إصدار 3.0.21";
Checking it
Bidi cannot be checked by looking, unless you read the script. A reversed line of Arabic still looks like Arabic.
So fingerprint it instead. Render a fixture, take the column-wise ink profile, and segment it into words on a run of clear pixels. It never looks at the text:
async function fingerprint(png, gap) {
const { data, info } = await sharp(png).greyscale().raw()
.toBuffer({ resolveWithObject: true });
const widths = [];
let start = -1, blank = 0;
for (let x = 0; x < info.width; x++) {
let inked = false;
for (let y = 0; y < info.height; y++) {
if (data[y * info.width + x] < 128) { inked = true; break; }
}
if (inked) { if (start === -1) start = x; blank = 0; }
else if (start !== -1 && ++blank >= gap) {
widths.push(x - blank + 1 - start);
start = -1; blank = 0;
}
}
if (start !== -1) widths.push(info.width - 1 - start);
return widths;
}
Compare that sequence against a reference from a browser laying out the same string, same size, same face. A run in the wrong order changes the sequence. A glyph that fails to shape changes its length. Store the browser’s numbers as fixtures, allow a pixel or two, and fail the build on a mismatch. Nobody running it has to read a word.
For the tags themselves, the platform validators are still the fastest check. Run them once per card design, not once per post.
A card generator is a text layout engine with one customer. Ordering, shaping, face selection, measurement — a browser does all of it for you, everywhere except the one image you ship that no browser renders. Latin-only pages never touch two thirds of this. The rest of us get the sections above in roughly the order the bugs arrive.