Before anything can be animated, we need to know what is on the page. That sounds trivial. For Malayalam it is the hardest part of the whole project, and almost everything later is shaped by it.
Characters in, glyphs out
In Unicode, a Malayalam word is a sequence of code points. On screen, it is a sequence of glyphs: shapes from a font. Between the two sits a shaper, and the relationship is nothing like one-to-one.
Who draws what: the font, HarfBuzz and the pen
"Draws" is a loose word here, so let's pin it down. Nobody and nothing in this pipeline moves a pen. Three separate things do three separate jobs.
The font file holds the shapes. A font like Manjari is a file containing, for every glyph, a ready-made outline: a closed loop of points joined by straight lines and smooth curves, like connect-the-dots with curves. Nothing in it says how a hand would write the letter. It is the finished ink shape, the edge of a filled blob. Here is the start of a real one, the outline of the vowel sign ാ, as SVG path data (M means "move to this point", Q means "curve towards this point"):
M460 -119 Q553 -119 …
Fill that outline with ink and you see the letter. That is all a screen or a printer does when it "draws" text.
To get a feel for what those numbers mean, here is a real letter, ജ: the outline Manjari stores for it. Drag any dot and the shape bends. Solid dots are points on the outline; the white dots are control points that pull the curve towards them without touching it. The text box below holds the same path as numbers, so you can also edit a number and watch the drawing change.
A whole letter is just a list of these commands. For a step-by-step explanation of what each command does, see the free SVG Path Visualizer or Yqnn's SVG Path Editor (open source, Apache 2.0).
Now compare it with the path Jayasree records for the same letter. The drawing below puts the two together: the filled shape is the font's outline, and the line with the dot is the single stroke a pen would follow, starting at the dot. That line is what gets animated.
Filled shape: the font's outline of ജ. White line: Jayasree's stroke for it, after processing.
HarfBuzz decides which shapes, and where. HarfBuzz is an open-source text-shaping library (used by Chrome, Firefox, Android and most Linux software). It never touches an outline. You hand it the code points and the font, and it consults the font's built-in rules: this sequence becomes that conjunct glyph, this vowel sign goes to the left of its consonant, nudge this mark up a little. Its answer is a list of glyph, x position, y position, advance, one row per glyph. That is what the output below shows. In short: the font knows what each shape looks like, and HarfBuzz knows which shapes a word needs and where each one goes.
A small helper turns an outline into data we can use. Jayasree asks the font file for each glyph's outline and copies its points into SVG path data. That is the d string stored in glyph-data.json, which a browser can fill or lay a stroke over.
So when this series says "HarfBuzz shaped it", the layout was worked out. When it says "Manjari draws it as one glyph", the font has a single outline for that conjunct. The outline is a filled shape, never a pen path, and that gap is exactly why Jayasree needs hand-recorded strokes: the installed font can tell us what a letter looks like, but not how to write it.
Here is real output from Jayasree's own shaping code, run against the Manjari font. The script is three lines:
from jayasree.strokes import shape_word
trace = shape_word("ചന്ദ്രൻ", "Manjari-Regular.ttf")
for g in trace["glyphs"]:
print(g["glyphName"], g["cluster"], round(g["x"]))
ch1 0 0
n1th3r3 1 1523
n1cil 6 3879
Seven code points went in; three glyphs came out. The middle one, n1th3r3, is a single glyph that Manjari draws for the whole conjunct (കൂട്ടക്ഷരം, several consonants joined into one shape) ന്ദ്ര: five code points (ന, the virama ് (ചന്ദ്രക്കല, the small sign that strips a consonant's vowel so it can join the next one), ദ, virama again, ര) fused into one shape. A few more examples from the same run:
| Input | Code points | Glyphs | Glyph names, left to right |
|---|---|---|---|
| മ | 1 | 1 | m1 |
| കി | 2 | 2 | k1, i1 |
| കൊ | 2 | 3 | e1, k1, a2 |
| ന്ദ്ര | 5 | 1 | n1th3r3 |
| ചന്ദ്രൻ | 7 | 3 | ch1, n1th3r3, n1cil |
| പ്രത്യേക | 8 | 5 | p1r3, e2, th1, y2, k1 |
Look at കൊ. Two code points, ക and the vowel sign (സ്വരചിഹ്നം, a mark that gives a consonant its vowel) ൊ, produce three glyphs, and the first one drawn is the left half of the vowel sign (e1), placed before the consonant k1. The right half (a2) follows after. A single Unicode character is drawn on both sides of its consonant. Reading order in the file is not drawing order on the page.
This is what HarfBuzz is for. It reads the font's OpenType rules, reorders, substitutes and positions glyphs, and gives back the answer. Jayasree uses it through uharfbuzz at build time, so the shipped JavaScript carries no shaper and no font file; just the results.
Clusters: the unit that matters
Because a conjunct like ന്ദ്ര is one visual unit, Jayasree works in clusters, not characters. A cluster is the smallest group of code points that must be drawn together. The pre-computed data file holds 2,052 of them: standalone letters, consonant plus vowel sign, conjuncts, and so on, each with its outline and advance width.
Every glyph's outline is stored as SVG path data in font units (Manjari uses 2,048 units to the em), with the y axis flipped because font space points up and SVG points down.
The dotted-circle trick
Here is the problem that drives the architecture. We have roughly 2,000 clusters but cannot ask someone to hand-draw 2,000 clusters. Most are a base plus a mark: a consonant plus a vowel sign, a conjunct plus an anusvara (അനുസ്വാരം, ം, a nasal "m" sound added after a letter). If we understood each mark as a reusable recipe, we would need to draw only the bases.
So how does a mark know where it goes relative to a base it has never met?
Shape the mark alone. HarfBuzz, faced with a combining mark that has nothing to attach to, inserts a dotted circle (◌, U+25CC, the same circle you see in a font chart when a mark is shown on its own) as a stand-in base. Now we have a measured layout: where the real glyph landed relative to the placeholder. Real output:
| Input | Glyph 1 | Glyph 2 |
|---|---|---|
| ◌ + ്ര | r4 at x = 0 | ◌ at x = 615 |
| ◌ + ാ | ◌ at x = 0 | a2 at x = 1131 |
| ◌ + െ | e1 at x = 0 | ◌ at x = 1395 |
Solid boxes are the real glyph; dashed boxes are the dotted-circle placeholder. Widths are to scale, in font units.
Read the picture and the recipe falls out. A mark that sits before its base (r4, e1) has its real content at the origin, and the base is shifted right by the width the mark occupies (615 and 1395 units). A mark that sits after its base (a2) has the placeholder first, and the real content starts at 1131, the circle's own width.
These numbers are not estimated, by a person or by an AI. They are read straight from HarfBuzz's output, with one small rule per field:
shiftis the x position HarfBuzz gives the placeholder. If the mark is drawn before its base, the placeholder lands to the right of it, and that distance is how far any real base must move right to make room. A mark that comes after its base leaves the placeholder at 0, so its shift is 0.trailingWidthis the extra room the mark takes up after the base: the total advance of the shaped run minus the x where the mark's own glyph starts. It is what you must add to the base's width so the next letter does not land on top of the mark. For ാ that is 1,001 units; for marks drawn entirely before the base it is 0.prefixandsuffixare the glyphs HarfBuzz placed before and after the placeholder.
The build script (_build_marks in tools/build_glyph_data.py) does this for each mark once, the same way every time. At runtime a composed cluster's width is simply shift + base width + trailingWidth.
Jayasree stores exactly this, per mark, as a small recipe: a shift (how far the base moves right), the prefix glyphs (drawn before the base), the suffix glyphs (drawn after it), and a trailingWidth (how much width the mark adds on the right). From the real glyph-data.json (path data trimmed):
"ാ": { "shift": 0.0, "prefix": [], "suffix": [ { "d": "M460 -119Q553 -119 …" } ], "trailingWidth": 1001.0 },
"്ര": { "shift": 615.0, "prefix": [ { "d": "M299 -1026Q328 -1026 …" } ], "suffix": [], "trailingWidth": 0.0 },
"െ": { "shift": 1395.0, "prefix": [ { "d": "M100 -432Q100 -563 …" } ], "suffix": [], "trailingWidth": 0.0 }
The file has 23 of these recipes: the virama, every vowel sign, anusvara, visarga (വിസർഗം, ഃ, a breathy "h" sound at the end of a syllable) and the subjoined ya, va, la and ra tails (്യ ്വ ്ല ്ര, the shapes a consonant takes when it hangs on the one before it). With them, any base can take any mark without anyone having shaped that particular pair. Part 5 shows what happens when this goes subtly wrong.
There is a catch worth flagging now. Because a mark's own ghost (the faint outline a person traces over to record its stroke, explained with a picture in Part 3) includes the placeholder, a stroke drawn over it is anchored at the placeholder's width (about 1,131 units for the trailing marks), not at zero. Forgetting that offset caused one of the project's four real composition bugs.
Vowel signs that are really two
Three Malayalam vowel signs, ൊ, ോ and ൌ, are drawn on both sides of the consonant. One stroke cannot represent that, so Jayasree does not try. They decompose into their parts, and Unicode agrees exactly:
ൊ U+0D4A → U+0D46 U+0D3E (െ + ാ)
ോ U+0D4B → U+0D47 U+0D3E (േ + ാ)
ൌ U+0D4C → U+0D46 U+0D57 (െ + ൗ)
(That table is real unicodedata.normalize("NFD", …) output.) Each half is a simple one-sided mark, so the same recipe machinery applies twice in a row. ൈ is the one exception: it has no decomposition and Manjari draws it as a single prefix glyph, so it is recorded as an ordinary atom.
Chillu: two spellings, one glyph
A chillu (ചില്ലക്ഷരം) is a consonant's bare final form, like the ൻ at the end of അവൻ. Since Unicode 5.1 it is a single code point. Before that, it was written as three:
ൻ U+0D7B (modern, atomic)
ന് U+0D28 U+0D4D U+200D (legacy: ന + virama + ZWJ, an invisible "zero-width joiner" character)
Shape both against Manjari and you get the same glyph, n1cil, with byte-identical outline data. They are visually indistinguishable in every font, which is exactly why this is dangerous: a user who typed one spelling and a data file keyed on the other will see "no stroke found" for a word that looks perfectly correct.
Many keyboards, messaging apps and older website and blog editors (content management systems, the software behind many sites) still produce the legacy form, so Jayasree has to accept both. Rather than duplicate every chillu-bearing entry under a second key, the runtime rewrites the legacy sequence to the atomic one as the very first step, for all six chillus (ൺ ൻ ർ ൽ ൾ ൿ). The mapping lives in the data file, not in code:
"legacyEncodings": { "ന്": "ൻ", "ര്": "ർ", "ല്": "ൽ", "ള്": "ൾ", "ണ്": "ൺ", "ക്": "ൿ" }
Hindi has the same problem in a different place. A nukta is a small dot under a Devanagari letter that gives it a new sound, such as क़ ("q"), ज़ ("z") and फ़ ("f"), used mostly for words borrowed from Urdu, Persian and English. Unicode can spell क़ two ways: as one precomposed code point (U+0958), or as क (U+0915) followed by the nukta sign ़ (U+093C). Both look the same, so, as with the chillu, the code has to treat them as one. That is a good sign the idea transfers.
What this buys us
After this stage we have, for every cluster, a Manjari outline we can draw over, and a recipe for every mark. We know, in advance and from real measurements, how any base and mark fit together. That is what makes it possible to ask a person to draw about 300 things and get about 2,000 back.
Next: how a person draws them. [Part 3: Teaching a font to draw.]
Prefer GitHub?
You can also discuss this post on GitHub Discussions. A GitHub account is needed there, and nothing loads from GitHub until you click.