---
title: "Drawing link previews that work in Arabic"
description: "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."
author: "Omar Albeik"
date: 2026-08-29
type: tutorial
topics: [typography, tooling, software, engineering]
language: en
reading_time_minutes: 23
canonical_url: https://omaralbeik.com/en/blog/generate-a-social-card-for-every-page
translation_url: https://omaralbeik.com/ar/blog/generate-a-social-card-for-every-page
source_url: https://omaralbeik.com/en/blog/generate-a-social-card-for-every-page.md
---

# Drawing link previews that work in Arabic

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.

<PostFigure
  src={unfurled}
  alt="Two messages in a messaging app. The received message on the left carries an English link that has unfurled into a preview: the card image, then the title, description and domain drawn under it by the app, then the URL and a timestamp. The sent message on the right is the Arabic version of the same link, its bubble green and its whole preview mirrored to read right to left."
  locale="en"
  eager
/>

## 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: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

```html
<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.

<PostFigure
  src={cardEn}
  alt="A link preview card: the word ARTICLE in small blue capitals above a short rule, then the post title in a large serif, a two-line description below it, and a footer with the site mark, the domain, the date and the reading time."
  caption="One card, and what is on it. No screenshot, no gradient — a link preview should look like the page it opens."
  locale="en"
/>

## 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.

<PostFigure
  src={ubsServiceEn}
  alt="An Unblock Syria service card on a dark green ground: the Coursera logo, the name Coursera in a large light serif, and two pill-shaped badges reading Blocked and Contacted."
  caption="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."
  locale="en"
/>

<PostFigure
  src={ubsServiceAr}
  alt="The same Unblock Syria card in Arabic. The whole layout is mirrored: the Coursera logo sits at the right, the name is written in Arabic as كورسيرا, and the two badges read محظور and تم التواصل."
  caption="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."
  locale="en"
/>

## Build time, in plain Node

The renderer runs after the build, as an ordinary Node script:

```json
"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:

```js
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:

```tsx
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.

```ts
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:

```tsx
// 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`:

```ts
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*:

```tsx
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" } });
```

<PostFigure
  src={ubsFallback}
  alt="The Unblock Syria brand card: the wordmark, the key emblem, and the line Syria is Free. Now Unblock It."
  caption="The last layer, and the only one whose correctness does not depend on the page it is standing in for."
  locale="en"
/>

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.

```ts
// 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.

<PostFigure
  src={sizingByCount}
  alt="Two rows. Above, in red, the letters EMSC drawn very large beside a much smaller line of Arabic. Below, in black, the same two names drawn at the same optical size."
  caption="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."
  locale="en"
/>

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.

```js
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.

<PostFigure
  src={stressArLong}
  alt="An Arabic card with a very long title filling three lines at a reduced size, with no description underneath and only the footer below it."
  caption="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."
  locale="en"
/>

## 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

<PostFigure
  src={rasterizerText}
  alt="The same Arabic sentence twice. Above, drawn by resvg's own text element: the Latin words appear as empty boxes and the first Arabic word has moved to the far left. Below, drawn as outlines: the Latin words render correctly and the sentence reads right to left in the correct order."
  caption="The same string through resvg's own text element and through the pipeline this post describes. 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."
  locale="en"
/>

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.

```js
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:

```js
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:

```js
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:

```js
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.

<Figure
  caption="Every Arabic service name in the catalogue, 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."
  lockDirection
>
  <DeadAir locale="en" />
</Figure>

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.

<PostFigure
  src={preshape}
  alt="Two rows of a single Arabic word. Above, in red, the letters in the wrong order. Below, in black, the same word correctly formed."
  caption="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."
  locale="en"
/>

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.

<PostFigure
  src={ubsNeutrals}
  alt="An Unblock Syria card in Arabic for the Go programming language. The name reads جو (جولانج) with the brackets correctly mirrored, beside the Go logo and a badge reading محدود."
  caption="Stored opening-then-closing, drawn the other way round. Nothing in the renderer does that on its own."
  locale="en"
/>

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.

```js
// 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

<PostFigure
  src={cardAr}
  alt="The Arabic version of the same card design. The heading reads right to left with the Latin words libVLC and Swift 6 embedded in it in the correct order, and the site mark and domain sit at the right."
  caption="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."
  locale="en"
/>

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.

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](https://www.unicode.org/charts/PDF/UFE70.pdf).
  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:

```js
// 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:

```js
// 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:

```js
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.
