Satori ne sait pas façonner l'arabe, et ce que nous avons livré à la place
Les images OG générées cassent sur l'arabe d'une manière précise et documentée. Voici la cause, les quatre correctifs qui ne marchent pas, celui qui marche, et le cas que nous n'avons pas résolu.
Nous générons les images Open Graph au build avec next/og, qui s'appuie sur Satori. En anglais, le résultat est exactement ce qu'on veut : un vrai composant, de vraies polices, aucun outil de design dans la boucle. En arabe, il produisait une carte fausse d'une manière qu'il a fallu du temps à seulement nommer.
Le titre sortait avec environ 40 pixels de trous entre chaque mot, comme une justification de journal ratée. Tout était lisible, rien ne manquait, et cela ressemblait à un bug d'espacement dans notre propre composant. Ce n'en était pas un.
La cause : mesurer sans façonner
L'arabe est une écriture connectée. Une lettre prend des formes différentes selon qu'elle est en début, milieu ou fin de mot, et les formes connectées sont plus étroites que les formes isolées. Transformer une chaîne en ces formes s'appelle le façonnage, et c'est une étape distincte de la mesure.
Satori mesure d'abord et additionne la chasse isolée de chaque lettre. Les glyphes finalement peints sont plus étroits que ce qui a été mesuré : chaque mot se retrouve donc dans une boîte plus large que son encre, d'une quantité qui varie mot par mot selon les lettres qui se connectent. C'est tout le bug, et il explique les trous comme tout ce qui suit.
Quatre correctifs qui ne fonctionnent pas
- word-spacing n'est pas du tout implémenté dans Satori. La déclaration est acceptée puis ignorée.
- display: block est ignoré : impossible de retomber sur une mise en page en blocs pour échapper à la mesure flex.
- Découper en mots et les espacer avec un gap flex semble correct et ne l'est pas. Chaque boîte de mot porte sa propre chasse finale : les écarts deviennent inégaux et certains mots se chevauchent.
- Découper la ligne en runs de script et les disposer en row-reverse la casse activement. Cela réordonne les mots : un titre disant Webflow, Framer et code plaçait le run Framer à l'extrémité de la ligne.
Ce dernier point mérite qu'on s'y arrête, car c'est l'erreur qui ressemble le plus à de la compétence. L'algorithme bidirectionnel ordonne déjà correctement une chaîne mixte unique. La découper en runs et les inverser à la main, c'est faire le travail du bidi moins bien que lui. Donnez-lui une seule chaîne et le bon séparateur.
Ce qui fonctionne, et le détail qui coûte un après-midi
Une espace insécable, U+00A0, se façonne correctement et serré là où une espace normale échoue. Mais c'est le comportement du séparateur à une frontière de script qui coûte du temps, et les résultats ne sont pas ceux qu'on devinerait.
// 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 worseC'est désormais vous qui gérez les retours à la ligne
Une espace insécable, par définition, ne provoque pas de retour à la ligne. Dès qu'elle corrige l'espacement, elle retire la césure au moteur de rendu et vous la confie. Cela demande une estimation de largeur, que Satori ne fournit pas : vous mesurez donc la police vous-même et codez une constante en dur.
/* 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;
}Celui que nous n'avons pas pu corriger
L'espacement était réglé. L'erreur de mesure sous-jacente non, et elle ne peut pas l'être depuis Satori. Comme la boîte de chaque mot dépasse son encre d'une quantité variable, les glyphes sont décalés différemment dans leurs boîtes, et le résultat visuel est un titre qui ne s'aligne pas sur sa propre marge.
Tout ce qui semble devoir déplacer l'encre a été essayé et mesuré : alignItems, direction sur la ligne, direction sur le mot, row-reverse, marginLeft auto et textAlign. Aucun ne la déplace, car aucun ne s'attaque à l'écart entre la boîte et l'encre. Ils déplacent des boîtes. L'établir avec certitude plutôt qu'à l'œil demande une vérification caractère par caractère plutôt qu'une capture.
Nous avons donc tranché. Les routes arabe et française livrent la carte anglaise. Le chemin de code RTL reste dans le dépôt, correct et inutilisé, avec un commentaire expliquant ce qu'il attend. Un aperçu de partage visiblement décalé de sa propre marge en dit pire sur le travail qu'une carte en anglais.
Conserver un chemin de code correct mais inutilisé, avec la raison consignée, coûte moins cher que de tout redécouvrir le jour où la bibliothèque amont saura façonner.
Si vous générez des images avec du texte que vous ne pouvez pas façonner
Rien de tout cela n'est propre à l'arabe. Toute écriture exigeant un façonnage ou un positionnement complexe se heurte au même mur : persan, ourdou, devanagari, thaï, khmer. Si vos images générées doivent porter du texte dans l'une d'elles, testez avec une vraie chaîne dans la vraie police avant de construire le gabarit autour, et préparez-vous à ce que la réponse soit que ce pipeline n'en est pas encore capable.

