Akshara अक्षर

Finding broken Indic text rendering on the web.


Every Indian consumer app ships Indic text. Almost none of it is tested, because the people doing the testing read English, and these bugs are invisible unless you read the script. A clipped shirorekha reads as the font looks a bit odd. A substituted font reads as the design looks a bit cheap. Nobody files those, and everybody sees them.

Akshara points a headless browser at a page and measures. Below is the most common finding, which is also the whole argument.

line-height: 1.0 — as shipped
शुभ विवाह की हार्दिक शुभकामनाएँ — आपके नए जीवन के लिए ढेर सारा प्यार
line-height: 1.62 — as it should be
शुभ विवाह की हार्दिक शुभकामनाएँ — आपके नए जीवन के लिए ढेर सारा प्यार
The same string, the same font, the same page. Devanagari's shirorekha sits above Latin cap-height and the vowel signs stack higher still, so a line-height chosen against English shears the tops off the line below.

01Running it

Two ways, and the first needs no terminal at all.

As a bookmarklet

Drag this to your bookmarks bar, then click it on any page. The full scan runs on the page you are looking at and the findings appear in a panel — click one to outline the elements it found.

अ Akshara ← drag me up to the bookmarks bar (⌘⇧B shows it)

Dragging not working? , then right-click the bookmarks bar → Add page… and paste it as the URL. Not into the address bar — Chrome silently strips javascript: when you paste there, which is the usual reason a bookmarklet does nothing.

Or skip bookmarks entirely

If that is still fighting you, paste the code into the console instead. It always works and there is nothing to save.

· open any page · ⌥⌘J (Mac) or Ctrl Shift J · paste · Enter.

Chrome asks you to type allow pasting the first time it sees a paste into the console. That warning is aimed at scams that talk people into pasting code they have not read — this code is right here, and you should read it before trusting it, exactly as that warning intends.

It works because it is not cross-origin: a bookmarklet is first-party script in a page you have already loaded, so it reads that page's DOM and fonts the way its own code would — including pages behind a login. A strict script-src policy will block it on a minority of sites; everything else is fair game. Nothing is sent anywhere.

As a command

Scanning someone else's URL drives a real browser, so it runs on your machine rather than in this tab. Node 20 or newer; there is nothing to sign up for.


    

02What it looks for

Ordered by how often each one bites in the wild.

clipping
Ink taller than the line box, measured from the canvas ink extent and compared against Latin set in the same font.
fallback
The brand font has no Devanagari, so English and Hindi arrive in different faces at different weights.
tofu
A missing glyph — usually a webfont subset that dropped the block. Found by bitmap-matching against .notdef.
truncation
slice(0, 20) cutting between a consonant and its vowel sign. .length counts code units; a letter is several of them.
linebreak
A cluster divided across two DOM nodes. Shaping does not cross an element boundary, so the conjunct never forms.
normalisation
क़ exists twice in Unicode. Both spellings render identically and compare as different, so search finds one and not the other.
numerals
Devanagari digits sitting beside ASCII digits in one string.
lang
No language tag, so a screen reader reads Telugu in an English voice.

03Watch it happen

Both of these run the real thing, in your browser, in your fonts. The measurements come from the same Canvas call the scanner uses, so the verdict flips exactly where CI would fail the build.

शुभ विवाह की हार्दिक शुभकामनाएँ — आपके नए जीवन के लिए ढेर सारा प्यार

line-height 1.02
ink — · line box — · — the height of Latin
—
keep 4 units
text.slice(0, n)
akshara.truncate(text, n)

Drag it. slice counts UTF-16 code units and lands mid-letter, leaving a dangling halant or a vowel sign decorating nothing. The right column counts aksharas.

04Check a string

The rendering checks need a real browser. These do not — truncation, normalisation and numeral mixing are decisions about the text itself, so they run here, instantly, on whatever you paste. Nothing leaves your browser.

try:

05Where a letter ends

Intl.Segmenter cannot tell you. It implements UAX #29 grapheme clusters, and those split क्षि, which is one letter to anyone who reads it.

क
U+0915base
◌्
U+094Dvirama
ष
U+0937base
◌ि
U+093Fmark
four code points, six code units, one letter → क्षि

So Akshara segments on the orthographic unit instead. A virama binds forward into the consonant that follows it; every combining mark binds backward onto the cluster it decorates.

akshara := Base Nukta? ( Virama Join? Base Nukta? )* Mark*

The trap that catches a naive implementation: every virama carries General_Category=Mn. Test is this a combining mark before is this a virama and you swallow the one character that binds the conjunct, and every cluster quietly splits in two.

06What it reports

Real output from the torture fixture in the repository, evidence included, so the call can be checked rather than trusted.

07Twenty Indian sites, measured

Run against 23 URLs — national and regional news, plus Wikipedia in three scripts as a control. Twenty had Indic text on the page scanned. Seventeen of those twenty raised at least one error or warning.

fallback
10 of 20 sites · 269 elements. A stack with no Indic face in it, so the browser substitutes silently. One site's single Arial, sans-serif meant 220 elements of Hindi rendered in whatever the device happened to have.
normalisation
10 of 20 · 39 elements. Text not in canonical form: 27 deprecated precomposed characters, 12 strings not in NFC. Renders identically, compares as different.
clipping
8 of 20 · 85 elements. Worst overrun 3.9px.
overflow
5 of 20 · 75 elements. Worst case a control 155px too narrow, cut with no ellipsis.
truncation
5 of 20 · 8 elements.
tofu
None — but see the caveat, this is the least trustworthy number here.

Three sites came back with nothing above a note, and they are worth naming because the failures are not: mathrubhumi.com, hindutamil.in and maalaimalar.com.

The audit found more defects in the scanner than classes of defect on the web — a wait strategy that could not load a quarter of the sample, a clipping check fooled by line-height: 0, and 141 truncation findings that turned out to be correctly spelled Telugu and Hindi. All three are written up, with the numbers and the caveats, in AUDIT.md.

08What it will not report

A linter is worth only as much as the false positives it declines to raise.

A word-final virama in Tamil or Malayalam

வணக்கம் and தமிழ் both end on a bare pulli, because that is how the language is spelled. Calling a trailing virama truncation would condemn two entire languages, so the rule is per-script: raised in Devanagari and Telugu, silent in Tamil, Malayalam and Sinhala.

A line break splitting a cluster

Chrome's line breaker turns out to be shaping-aware. It wraps between letters and will not split an akshara even under word-break: break-all — measured, not assumed. Shipping a check that can never fire would be dishonest, so linebreak looks instead for clusters divided across DOM nodes, which is real, and which Chrome cannot save you from.

09Installing

// scan a page
npx akshara-qc https://example.in --screenshot findings.png

// or fix the string at its source, with no browser involved
import { truncate, aksharaLength } from "akshara-qc";

truncate("नमस्ते दोस्तों", 3);   // "नमस्ते…"  never a dangling halant
"नमस्ते".slice(0, 4);            // "नमस्"    half a letter
aksharaLength("नमस्ते");         // 3         .length says 6

The exit code is non-zero when anything at or above --fail-on is found, so it drops straight into CI.