A compiled font file, the kind you install and type with, is a collection of filled shapes. It holds only the finished letter, like the answer to a sum with none of the working, and nothing about how it was drawn. The Manjari file Jayasree draws over has no record of where the pen starts on ജ, which way it loops, or where it lifts.
That does not mean the information never existed. Type designers often draw with pen paths first and keep them in the font's source files. SMC's repositories for Manjari and Chilanka do exactly that, as SVG sheets, and mlmash builds on them (see Part 1). Those sheets are not part of the font you install, and in our first experiments with them we found a few things to work through: one file can hold several alternative drawings of a letter, and the direction and order of the strokes did not always match how a person writes the letter. This is early work, and I may be missing something, so I will come back to it in a later part of the series with proper results. For now, Jayasree takes its pen information from a person, which also lets us choose the standard form of each letter.
This part is about how a person gives it to the computer, and how the result is stored and protected, because that hand-made data is the actual content of the project. Everything else in the repository can be regenerated; this can only be redrawn.
The pipeline at a glance
The four stages. Everything except the highlighted file is generated.
- Ghost outlines. HarfBuzz shapes every cluster (Part 2) and writes outlines and advance widths to
glyph-data.json. - Record strokes. A person draws over each ghost in a browser tool. This produces
stroke-data.raw.json. - Process. Four toggleable passes clean the raw strokes (Part 4) and compose derived ones.
- Animate. The JavaScript runtime loads the data, composes anything not already stored, and draws it.
The recorder
The recorder is a single browser page, tools/stroke-recorder.html. It shows a pale ghost of the letter, taken straight from Manjari, and a person traces over it with a mouse, stylus or finger. Each pen-down-to-pen-up gesture is one stroke. The order of strokes and the direction of each one are the information the installed font does not carry.
The ghost is the font's own outline of the letter, drawn faintly, like tracing paper under your pen. It is not the letter being taught. It is a guide that says "the letter is roughly here, this big, this shape", so a person can follow its shape while deciding where the pen should start, which way it should go and in what order. Here it is for ജ: the ghost alone, the same ghost with a hand-traced stroke over it (wobbles and all, this is the raw recording), and the stroke on its own, which is what Jayasree keeps.
Left to right: the ghost (Manjari's outline), the raw stroke traced over it, and the stroke on its own. The dot marks where the pen went down.
The ghost does a second job later: the clean-up stage measures how far each stroke wanders from the ghost's middle and nudges it back (Part 4).
A few design decisions that mattered:
- Draw only what cannot be derived. Of about 2,050 clusters, only about 300 atoms need a hand-drawn stroke: standalone characters, consonants fused with certain vowel signs, and the roughly 108 conjuncts that form true ligatures. The dropdown shows only that reduced set by default. A contributor is never led to believe they must draw thousands of combinations.
- Make progress visible. A contributor can always see which letters are already drawn and which are not, and jump straight to the next missing one, so the size of the job never feels unknown.
- Allow an escape hatch. "Add custom cluster" lets you draw something freehand with no ghost, for words nobody planned for. Those strokes skip the centring pass, because there is nothing to centre against.
- Work offline.
build_standalone_recorder.pybakes the recorder and its data into a single HTML file you can open on any machine, including a separate build for Hindi. - Refuse what is not handwriting. Spaces and punctuation are rendered as plain static text, never as strokes, and the recorder will not let you add them. This is enforced in CI too, not left as a convention.
All of the Malayalam strokes were drawn by me, on a Samsung Galaxy Tab S10 FE tablet.
What the data looks like
The raw file is a dictionary keyed by cluster. Here is the beginning of ജ's entry, exactly as recorded:
{
"ജ": {
"strokes": [
{ "d": "M 274 -820 C 272 -821 269 -820 269 -824 C 270 -827 270 -825 276 -828 C 282 -831 …" }
]
}
}
Each stroke is an SVG path in the same font-unit space as the ghost. The recorder smooths input as it captures it, which is why the raw file is already made of cubic Bézier segments, but it is dense: ജ's single stroke has 733 of them. In Part 4 that falls to 39.
The numbers, as of this writing:
| File | Contents | Size |
|---|---|---|
glyph-data.json | 2,052 clusters, 23 mark recipes | ~5.5 MB |
stroke-data.raw.json | 297 hand-drawn atoms | ~6.8 MB |
stroke-data.json | 1,721 processed and composed clusters | ~6.1 MB |
Hand-drawn data grows faster than anyone expects: a few hundred letters, and the files already run to several megabytes. The remaining clusters up to the full 2,052 are composed in the browser at run time (Part 5).
Protecting hand-made data
Code can be rewritten. Hand-drawn strokes cannot be regenerated, though they can be redrawn, and redrawing hundreds of letters by hand is days of work. A single careless script that rewrites them in bulk would be a costly mistake that diff review might not catch. The repository protects this data in layers:
- Structural validation.
tools/validate_data.pychecks every committed data file on every commit. It deliberately uses only the Python standard library, because the pre-commit hook runs with a barepython3where the HarfBuzz bindings may not be installed. The cost is one duplicated constant, which is an acceptable price. - A content snapshot.
python/tests/test_data_snapshot.pyhashes the stroke content of every previously approved cluster. If any hash changes or goes missing, the test fails. New clusters pass. Re-approving a changed one takes an explicitmake update-snapshot, committed in the same pull request. That friction is the point. - Source versus generated. The raw file is written only by the recorder.
glyph-data.jsonandstroke-data.jsonare generated bymake build-glyph-dataandmake process-strokes. The rule, written into the contributor guide, is: regenerate, never patch. - Review and git history. The data lives in the repository itself, as plain JSON files in git, not zipped, not in Git LFS (large-file storage) and not somewhere else. So every change to it is a reviewable commit, with a normal history and diff. That works today. The files already run to several megabytes, and as more languages arrive, moving the data to LFS or a separate store is something to consider. For now, keeping it in plain git is a deliberate choice.
- A read-only runtime. The JavaScript only reads data; nothing at run time writes back.
Part 4's last section shows this machinery earning its keep: a smoothing fix that would silently change 854 of 1,721 already-approved Malayalam clusters, and the decision not to do that casually.
Up next
We now have raw strokes: correct in shape and order, but shaky, off-centre and slightly crooked, because a human hand drew them on a screen. [Part 4: Making shaky strokes clean.]
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.