Satori cannot shape Arabic, and what we shipped instead

Generated OG images break on Arabic in a specific, documented way. Here is the cause, the four fixes that do not work, the one that does, and the case we could not solve.

We generate Open Graph images at build time with next/og, which runs Satori underneath. For English the result is exactly what you want: a real component, real fonts, no design tool in the loop. For Arabic it produced a card that was wrong in a way that took a while to even name properly.

The headline came out with roughly 40 pixel holes between every word, like badly justified newspaper type. Everything was legible, nothing was missing, and it looked like a spacing bug in our own component. It was not.

The cause: measurement without shaping

Arabic is a connected script. A letter has different forms depending on whether it sits at the start, middle or end of a word, and the connected forms are narrower than the isolated ones. Turning a string into those forms is called shaping, and it is a separate step from measuring.

Satori measures first and sums each letter's isolated advance width. The glyphs that eventually paint are narrower than what was measured, so every word ends up in a box wider than its own ink, by an amount that varies word by word depending on which letters connect. That is the whole bug, and it explains both the holes and everything that follows.

Writing

Want this done on your site?

Book a call

Four fixes that do not work

  • word-spacing is not implemented in Satori at all. The declaration is accepted and ignored.
  • display: block is ignored, so you cannot fall back to normal block layout to escape the flex measurement.
  • Splitting into words and spacing them with flex gap looks right and is not. Each word box carries its own trailing advance, so the gaps come out uneven and some words collide.
  • Splitting the line into script runs and laying them out row-reverse actively breaks it. It reorders words: a headline reading Webflow, Framer and code put the Framer run at the far end of the line.

That last one is worth dwelling on, because it is the mistake that feels most like competence. The bidirectional algorithm already orders a single mixed string correctly. Splitting it into runs and reversing them by hand is you doing bidi's job worse than bidi does it. Give it one string and the right separator.

What does work, and the detail that costs an afternoon

A non-breaking space, U+00A0, shapes tightly and correctly where a normal space does not. But the separator behaviour at a script boundary is where the time goes, and the results are not what you would guess.

// Separator behaviour at an Arabic-then-Latin boundary, measured
// against rendered output rather than assumed.

"\u00A0"          // ONE nbsp   -> space vanishes: "تطويرWebflow"
"\u2009"          // thin space -> dropped the same way
"\u2066…\u2069"   // LRI / PDI isolates -> dropped the same way
"\u00A0\u00A0"    // TWO nbsp   -> survives
" "                // plain space -> survives, but re-splits the bidi
                   //    run and reverses word order, which is worse
None of this is documented. Each line is a measurement off real rendered output, which is the only way to establish any of it.

You now own line breaking

A non-breaking space, by definition, will not wrap. So the moment it fixes the spacing it takes line breaking away from the renderer and hands it to you. That needs a width estimate, and Satori will not give you one, so you measure the font yourself and hard-code a constant.

/* Measured off rendered output: IBM Plex Sans Arabic sets at about
   0.41em per character. 0.45 leaves headroom without breaking early.
   It was 0.54, which put a 34-character headline onto three ragged
   lines with one word alone on the last. */
const CHAR_EM = 0.45;

/** Greedy packer. Whole words, never overflowing the box, because
 *  satori cannot measure for us and CHAR_EM stands in for it. */
function rtlLines(text: string, fontSize: number, boxWidth: number) {
  const maxChars = Math.max(8, Math.floor(boxWidth / (fontSize * CHAR_EM)));
  const lines: string[][] = [];
  let cur: string[] = [];

  for (const word of text.split(/\s+/).filter(Boolean)) {
    const next = [...cur, word];
    if (cur.length && next.join(" ").length > maxChars) {
      lines.push(cur);
      cur = [word];
    } else {
      cur = next;
    }
  }
  if (cur.length) lines.push(cur);
  return lines;
}
Counting characters rather than measuring glyphs is crude, and it is the only option available inside Satori. Tune the constant against real headlines, not against a formula.

The one we could not fix

The spacing was solved. The underlying measurement error was not, and it cannot be from inside Satori. Because every word's box is wider than its ink by a varying amount, the glyphs sit inside their own boxes offset by different amounts, and the visual result is a headline that does not align to its own margin.

Everything that looks like it should move the ink was tried and measured: alignItems, direction on the line, direction on the individual word, row-reverse, marginLeft auto, and textAlign. None of them move it, because none of them are addressing the box-versus-ink gap. They move boxes. Establishing that with certainty rather than by eye needs a per-character check instead of a screenshot.

So we made a call. The Arabic and French routes ship the English card. The RTL code path stays in the repo, correct and unused, with a comment explaining what it is waiting for. A share preview that is visibly off its own margin says something worse about the work than an English one does.

Keeping a correct-but-unused code path, with the reason written down, is cheaper than rediscovering all of this the day the upstream library learns to shape.

If you generate images with text you cannot shape

None of this is specific to Arabic. Any script that requires shaping or complex positioning hits the same wall: Persian, Urdu, Devanagari, Thai, Khmer. If your generated images have to carry text in one of them, test with a real string in the real font before you build the template around it, and be prepared for the answer to be that this pipeline cannot do it yet.

Let's build the site your business deserves.

Send what you have now and where you want it to go. You get a straight answer on scope, timeline and cost, usually within the hour.

Working across North America, Europe and the Middle East.