We ship a small dark preview card whenever an Anvix link is shared — the kind of image that shows up under a URL in WhatsApp, Slack, or X. Ours ends with a line about the word behind our name: Anvīkṣā(अन्वीक्षा), the Sanskrit idea of reasoned inquiry. That single Devanagari word turned a five-minute asset into a genuinely interesting rendering problem, and it's a problem anyone shipping Indian-language text into generated images will eventually hit.
The trap: it looks like a text-drawing job
Drawing Latin text into an image is close to trivial. Every glyph maps to one character, they sit left to right, and almost any library — canvas, satori, an SVG rasteriser — gets it right. So the instinct is to reach for the tool you already know. In a Next.js project that usually means ImageResponse from next/og, which renders JSX to PNG using satori under the hood.
Devanagari breaks that instinct. It is not a one-glyph-per-character script. Two things happen that simple text drawing does not handle:
- Reordering. Some vowel signs are typed after a consonant but drawn before it. The short-i matra is the classic example — the code points are in one order, the glyphs render in another.
- Conjuncts. Consecutive consonants fuse into a single ligature. In अन्वीक्षा the cluster k + virama + ṣabecomes the single conjunct glyph क्ष — it is not "क then ष", it is a new shape the font provides.
Turning a run of code points into correctly positioned glyphs is called shaping, and it is a real piece of software — HarfBuzz is the one almost everything serious uses. If your rendering path does not run a shaper, Devanagari comes out as disconnected base letters with matras floating in the wrong place. It doesn't crash; it just looks broken to anyone who reads the script. That's the worst kind of bug — invisible to the person who shipped it.
Why the popular OG tools fall short here
satori (and therefore @vercel/og / next/og) is excellent for Latin and does an enormous amount right, but its text layout does not do full complex-script shaping. Raw <canvas>text has the same limitation — the browser canvas shapes text when it draws to screen, but headless server-side canvas builds frequently don't carry the shaping stack. The result is the same either way: your English renders beautifully, your Hindi or Tamil looks subtly wrong, and you don't notice until a native reader tells you.
The fix is not a clever workaround. It is to render through something that actually shapes.
What actually works: rasterise SVG through a real shaper
Our build already had sharp available. sharp wraps libvips, and libvips rasterises SVG through librsvg / resvg — both of which do proper HarfBuzz-class shaping and pull glyphs from system fonts via fontconfig. So the whole job reduces to: write an SVG with a <text> element, hand it to sharp, get a PNG back. The shaping happens for free, correctly.
const sharp = require("sharp");
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="120">' +
'<rect width="1000" height="120" fill="#0a1929"/>' +
'<text x="20" y="70" font-size="34" ' +
' font-family="Segoe UI, Nirmala UI, sans-serif" fill="#cbd5e1">' +
'From Anvīkṣā (अन्वीक्षा) — analytical inquiry' +
'</text></svg>';
await sharp(Buffer.from(svg)).png().toFile("out.png");One thing to get right: the font-family stack needs a font that hasDevanagari coverage, and it has to be installed where the image is built. On Windows that's Nirmala UI; on a typical Linux CI box you'll want to install something like Noto Sans Devanagari and let fontconfig find it. The Latin diacritics (the ī and ṣ in the transliteration) come along for free from the base font.
The second half: matching an existing design exactly
Rendering correct text is only half the job when you're editing one line of an existing card. The rest of the image — logo, headline, the faint background grid — had to stay untouched, and the new line had to match the old one's size, weight, colour, and baseline. There was no source file to regenerate from, only the baked PNG.
So we treated it as a measurement problem instead of a guessing one. Font size is a good example: rather than eyeball it, we measured the pixel width of a stable word in the original image, rendered the same word at several sizes, and picked the size whose width matched.
// The word "From" is 41px wide in the original.
// Render it at candidate sizes and compare:
// font-size 18 -> 38px
// font-size 19 -> 40px <- closest match
// font-size 20 -> 43px
// => 19px is the size the original was rendered at.The same idea fixed vertical placement (sweep the baseline until the rendered top/bottom line up) and colour (sample the thickest stroke — an em-dash is perfect — to read the true fill without anti-aliasing noise). Then we composited only the corrected line over the original: paint the old text out with the background colour, draw the new line on top. A pixel diff confirmed the win — a few thousand changed pixels, all inside the text band, and the logo and headline byte-for-byte identical.
Treat "match this design" as a measurement task. Pixels are data; guess less, sample more.
The takeaways
- If you generate images with Indian-language (or Arabic, or Thai) text, your renderer must shape. Verify with a real conjunct — अन्वीक्षा is a good torture test — not just isolated letters.
- satori /
next/ogand headless canvas are great for Latin but don't assume complex-script shaping. An SVG rasterised through librsvg/resvg (via sharp) does shape, and needs no new dependency if sharp is already in your stack. - Ship the right font to the build environment, not just to your laptop. "Works locally" is where this class of bug hides.
- When you have to match an existing asset, measure it. Width, baseline, and colour are all readable straight off the pixels.
None of this is exotic — it's the difference between an image that looks right to everyone and one that looks right only to people who can't read the script. For a company named after a Sanskrit word, getting that word's own glyphs right felt like the least we could do.