Tutorial20 min read

Drawing link previews that work in Arabic

One Open Graph image per route, built two ways: HarfBuzz and resvg at build time, satori per request. Then the part that breaks in Arabic — word order, letter joining, and measuring text the font draws differently than it measures.

View as Markdown

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 — so nothing a browser does for text is done for you here.

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

The working checklist. Everything after it is the explanation, and the part you will want when one of these bites.

  • One card per route, og:image absolute, with width, height, alt and twitter: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, briefly

<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" />

The URL must be absolute; crawlers do not resolve a relative one. Without twitter:card set to summary_large_image you get a small square thumbnail instead of the card you drew. og:title, og:description, og:url and og:type still have to be there as well.

1200×630 is what everything accepts, and platforms crop it differently — X to 16:9 in my testing. Keep the text inside a margin. Mine is 96px, arrived at by looking at crops: nobody publishes a safe area, only an image size.

One card, and what is on it. No screenshot, no gradient — a link preview should look like the page it opens.

Where the card gets drawn

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 and the renderer can be any program you like. On omaralbeik.com that is an ordinary Node script run after the build, reading each built page’s own <head> for the title and description, so a card cannot disagree with the page it previews.

"build": "astro build && node scripts/og-render.mjs"

Outside the framework on purpose. With the Cloudflare adapter, Astro prerenders routes inside workerd, where there is no filesystem to load a font from and no way to load a native rasterizer.

If the content can change, draw per request. An unblocksyria.com service card carries the blocking status and the outreach progress, and either can change without a deploy. A build-time image would be a picture of the catalog on deploy day.

A card that cannot be drawn at build time. Both badges are live state — whether the service is blocked, and how far the outreach has got — and either can change without a deploy.
The same route in Arabic. The card mirrors whole — logo, name, badges and wordmark all swap sides — and the Latin wordmark stays Latin inside it, which is why both font families are loaded on every card whatever the locale.

That route is ImageResponse from next/og — satori for layout, a rasterizer behind it. Fonts are the awkward part, and the part that matters later. There is no filesystem on Workers, so the faces are base64 in the bundle: two weights each of a Latin and an Arabic face, 129KB of font binary and 172KB after base64, on every deploy. Both families ship whatever the locale, because a Latin wordmark sits inside the Arabic card and an Arabic name sits inside the English one.

WOFF, not WOFF2 — satori cannot read WOFF2, and it fails by throwing rather than by falling back to a default face.

Every path ends 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.

So the per-request path needs a fallback, and the obvious code puts the failure where the catch cannot see it: new ImageResponse(...) returns before it rasterizes. arrayBuffer() is where the work is forced, so call it inside the try.

export function renderCard(element: ReactElement): Promise<ArrayBuffer> {
  return new ImageResponse(element, { width: OG_WIDTH, height: OG_HEIGHT, fonts })
    .arrayBuffer();
}

Then fall back in layers: the full card, the card without the remote logo — the most likely thing to have failed — and last a brand card that draws no page data at all, so no page can break it. Cache a missing slug like any other card and a 5xx not at all. Cache that one and you pin the generic card onto a real page’s link for the whole stale-while-revalidate window.

The last layer, and the only one whose correctness does not depend on the page it is standing in for.

Fitting the text

Given a real measure function, wrapping is ordinary: greedy wrap to a line limit, an ellipsis when text is left over, a mid-word break for a token wider than the column. Estimates are where it goes wrong, and across two scripts they go wrong by about a factor of two. Sizing a title by character count set “المرصد الأورومتوسطي لرصد الزلازل” at 28px beside an “EMSC” at 104px — both ordinary names for the same kind of organization.

The same two names sized two ways. Character count is not a measurement. It is a guess about a script, and here it is wrong in both directions at once.

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.

A title nobody trimmed. The type has stepped down and the description is gone rather than the block overrunning the frame. Keep a set of these: real cards are well-behaved, which makes them useless for finding bugs.

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

The same string through resvg's own text element, then through the pipeline below. Two failures at once in the top row: the runs are in the wrong order, and the Latin words are boxes because the Arabic face cannot draw them and nothing fell back.

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 neighbors, 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 catalog 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 canceled 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.

Dead space per word across the catalogueA histogram of how far each Arabic word's measured box misses the width it is drawn at, over 1,127 distinct words from the Unblock Syria catalogue. The distribution runs from −0.14em to 3.49em with a median of 0.91em. Seventeen words fall below zero, meaning they are drawn wider than the box measured for them. The bulk sits between 0.25em and 1.5em, and a long tail reaches past 3em.wordsdrawn as measuredmedian 0.91em012317 words drawn wider than their boxdead space per word, in em
Every Arabic service name in the catalog, measured with the advances the cards are drawn from. Most words lose between a quarter and one and a half em. The red bin is the seventeen that overrun their box instead — which is why no single correction works.

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.

One word, two orderings. Not a missing glyph and not a broken join: a real word that has become a different one. Nothing downstream can catch that.

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.

Stored opening-then-closing, drawn the other way round. Nothing in the renderer does that on its own.

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 Arabic card from the same generator. The layout mirrors off a single value — which edge reading starts from — so the two languages differ in what the glyphs do, not in where they sit.

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:image is 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.
  • A query string in the image path. Derive the card URL from the canonical path and clear the search first. Appending to /articles?page=2 puts the suffix inside the query value, and every crawler past page one gets the HTML page where it expected a PNG.
  • opengraph-image as a usable slug. Reserve it. A framework that resolves a static segment before the dynamic one beside it lets a row slugged that shadow its own collection’s card.
  • Advertising a card you never drew. A route rendered per request has no built HTML for a build-time script to read. Check at the end of the run that every advertised card exists on disk.

Then the Arabic ones. They share one property: 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: right as 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 meet it in roughly the order the bugs arrive.

Topics