Prologue

One of my pastime activities is watching YouTube videos while sipping a beer. I generally watch videos on topics of science, technology and sometimes I watch videos on things I wished I had known and worked on but never did. And one day I got suggested a video from a channel called Sebastian Lague who has a series called Coding Adventure. His videos have amazing and eye-pleasing animations, and a soothing voice explaining complex concepts to a beginner. While browsing his collection of videos I came across one titled Coding Adventure: Rendering Text.

Fonts and Rasterization

In Sebastian’s video he goes on to explain how fonts themselves have a really complex specification and more specifically how you rasterize fonts for low resolution displays (like an LED badge). Even though the section is quite small, I went through the original document by Microsoft called Fixing rasterization issues. This document also visualized for me the exact nature of the problem I was facing.

The double width and the missing pixels felt oddly familiar.
The double width and the missing pixels felt oddly familiar.

The TL;DR is, it is not trivial and there is a lot of math and manual work (font hinting) involved and this kind of pointed back to what Ashik had mentioned in my earlier post about building the glyphs by hand for low resolution displays.

Sebastian goes on to the required tables within a font specification and then discusses glyf tables, how to figure out the points connecting the parts of the glyph, and then came the pièce de résistance, Bézier curves.

Bézier curves

Back in school (and later in college), my basic intuition for visualizing things in 2-D coordinate geometry wasn’t so great. And in my 6th semester in college, I came across the concept of Splines and Bézier curves.

Not my core strength.
Not my core strength.

To some extent it frustrated me in two ways,

  1. I had to now re-learn some of the old formulas used for these, specifically linear interpolation.
  2. My skills in visualization were still very weak.

Bézier curves (especially the cubic and quadratic varieties) play an important part in shaping paths, for example a racing track layout in a game, but the same technique is also used for deciding curves in a font’s glyph.

അ in malayalam, real glyphs have curves ...
അ in malayalam, real glyphs have curves ...

Santhosh Thottingal in his paper titled Parametric type design in the era of variable and color fonts illustrates how glyphs in Malayalam have different curves by taking the example of a single glyph “അ”, where he shows how slanting can be applied to the glyph.

Asking LLMs the right question

With this newfound knowledge, half drunk, I decided to ask the LLM

Compare Nupuram-dots and k8x12 fonts, tell me what is fundamentally different between them.

The reason for choosing Nupuram-dots was I was still under the illusion that “dots” would make it more friendly for the LED badge display.

Claude went on to explain (abbreviated)

k8x12 (the pixel font I had been using for ASCII text on the badge) is an outline TTF whose outlines are pixel squares — rendering at its design size is pixel-perfect. Malayalam can never be “one codepoint = one glyph” (conjuncts, chillus, matra reordering), but the shaping machinery is independent of what outlines look like.

When asked

Is it possible to get a k8x12 like format for malayalam?

I also hinted to use something like FontForge, Claude replied

split “k8x12-like” into two properties (fixed pixel grid; what transfers to Malayalam).

When I probed further

“Is a monospaced Malayalam font technically possible?” and “theoretical minimum pixel height for Malayalam?”

Claude estimated 11px should be sufficient and was able to render “മലയാളം” (Malayalam).

Not bad ... we are getting somewhere with this
Not bad ... we are getting somewhere with this

This is better than most of the other brute force attempts I did, so I went ahead.

Nupuram almost renders with a few pixels off, is it possible to create a ttf based on nupuram but for led display where the currently rendered font is mapped like k8x12 and then handedited and fixed?

Claude replied

(1) auto-rasterize every glyph at target ppem
(2) vectorize k8x12-style as pixel-square contours
(3) quantize hmtx advances + GPOS anchors to the grid
(4) keep cmap/GSUB/GPOS verbatim so HarfBuzz shapes identically
(5) Hand-edit loop. Rather than nudging squares in FontForge, I’d make the source of truth a directory of text-art bitmaps — one small ●· grid file per glyph, exactly like the previews you’ve been reading in the terminal — with a fontTools script that compiles the directory into the TTF. Fixing a glyph = editing characters in a text file and recompiling. That’s also a workflow where you can hand me ‘ഷ്’s mark is one pixel too low’ and I can fix it directly.

Always be skeptical about LLMs, they are nothing more than a stochastic
parrot.
Always be skeptical about LLMs, they are nothing more than a stochastic parrot.

So this time I went with the classic example I have been using in my previous posts “കേരളം” (Keralam)

Woah!! Looks even better
Woah!! Looks even better

But how would it look when I push it to the badge?

Nailed it!!!
Nailed it!!!

Even though the LLM had made a breakthrough, I had no code to show for it and I needed to formalize this.

I was too drunk by the end of this to do anything useful...
I was too drunk by the end of this to do anything useful...

You can read the historical context of the conversational exchange if you are interested.

The birth of pixelshaper

The next day, I asked Claude to formalize this and make an application out of it. The choice of Python as the language was biased: since the Malayalam LED Simulator and the LED name badge tooling were written in Python, I stuck to the pattern.

Why does Malayalam (and other Indic family) have this problem?

As stated by Claude

Outline fonts fail on tiny pixel grids (LED matrices, e-paper tickers, embedded displays) for a precise reason: below ~12 pixels per em, strokes go sub-pixel and every edge rounds arbitrarily — nobody decides which pixel a stroke owns. Classic pixel fonts (k8x12, Terminal Vector) solve this by hand-drawing every glyph on a fixed grid, but they exist only for scripts where one codepoint is one glyph. Complex scripts — Malayalam, Devanagari, Arabic — need a shaping engine to form conjuncts, reorder vowel signs, and position marks, and no pixel-font toolchain speaks shaping. Consequently, no Indic pixel-font ecosystem exists.

I could not have put it better myself, but as with any LLM the claim below ~12 ppem may not be a hard limit depending on the glyphs used for display. This is more visible as we get into the examples.

This also gave me insights into why text in Santhosh’s Malayalam LED Simulator became illegible at lower pixel height values.

Basic Terminology

Before I go into the details, let me run through some quick terminology in typography that needs explanation. Of course none of this explanation is by me, it was done by the LLM. But it also helped me understand what it was trying to do. The concept of em is something I do not fully grasp, but it also plays a crucial role in rendering the text in LED badge.

  • Donor Font

    A Donor Font is nothing but the actual font that has the outline glyphs, from which we will generate a new font whose outlines are replaced with the equivalent pixel / bitmap ones.

  • em and ppem

    The em is the font’s design canvas — the square of abstract space inside which every glyph is drawn, and the unit that all font measurements are expressed in.

    In a digital font, the em is a coordinate grid. The font picks a resolution for it — units per em (upem): 1000 for PostScript-heritage fonts, 2048 for TrueType (Manjari, Mukta, Noto), sometimes 1024 or 4096.

    The em is nominal, not a bounding box. Glyphs may — and do — extend outside it. Latin’s f and j nose out a little; Malayalam breaks it spectacularly: a traditional-orthography stack like സ്ത്രീ piles marks below the base until the ink span exceeds the em entirely.

    ppem = pixels per em — the number of pixels the font’s em occupies when rendered. It’s the single number that converts between the font’s abstract design space and the actual pixel grid.

  • cmap — characters → glyphs (the entry point)

    The character map answers one question: “Unicode codepoint U+0D15 (ക) — which glyph ID in this font draws it?” It’s a simple lookup table, and it’s the only table that deals in Unicode at all. Everything after cmap operates purely on glyph IDs.

    The crucial limitation: cmap maps one codepoint to one glyph. That’s fine for Latin A, but Malayalam ക്ഷ is three codepoints (ക + ് + ഷ) that must become one drawn shape — cmap can’t express that. It also can’t be reached “backwards”: a conjunct glyph like ക്ഷ has no codepoint of its own, so it’s invisible to cmap entirely. This is why you can’t test a pixel font by just typing characters into a dumb renderer — anything beyond the base alphabet only exists past the next table.

  • GSUB — glyph substitution (which glyphs)

    GSUB rewrites the glyph sequence after cmap. It holds rules — organized by script/language and OpenType features (akhn, pref, blwf, liga, …) — of the form “this sequence of glyph IDs becomes that one” (and other flavors: one→many, one glyph→a contextual alternate, etc.).

    All the Malayalam behavior we’ve been wrestling with lives here:

    • ക + ് + ഷ → the single ക്ഷ conjunct glyph (akhn — the substitution we added the conjunct trace for in pixelshaper).
    • ് + യ → the ്യ sign form — and which glyph the rule targets matters: for a word like കമ്പ്യൂട്ടിങ് (“computing”), Noto substitutes a compact right-attached hook glyph while Manjari substitutes a deep below-base loop glyph, so Noto fits more of the strip height.
    • ര + ് reordered prefixed form, chillu formation (ന + ് + ZWJ → ൻ), etc.

    Key insight for our pipeline: GSUB decides which drawings appear. When pixelshaper bakes a pixel font, the donor’s GSUB is what makes the conjunct glyphs reachable at all — our traced art replaces the outlines, but the substitution rules routing text onto those glyphs come from the donor font.

  • GPOS — glyph positioning (where they go)

    After GSUB has settled which glyphs exist, GPOS adjusts their coordinates: kerning pairs (move A and V closer), mark attachment (place a vowel sign relative to an anchor point on its base — e.g. snapping ു to the right spot under different-width bases), cursive connection, mark-to-mark stacking. Rules are again grouped under features (kern, mark, mkmk, abvm, blwm).

    GPOS never changes what is drawn, only offsets and advances. In our 11 px world this is the table whose effects you see as sub-pixel-ish nudges that then get quantized: a GPOS mark anchor that places ്യ 0.3 px lower in em-space can be the difference between the stack fitting the strip or clipping below it once rounded to the pixel grid.

Engineering or The Art of Approximation

As an Engineer you almost always never get what you want so you end up approximating what you have.

Fonts in computers are as much Aesthetics as Engineering. A lot of mathematics goes into making sure a glyph in a font is very accurately represented. Fonts not only capture the text to be displayed but also the style in which it is displayed, “blocky” / “cursive” etc. Malayalam (as with many Indic languages) has a very curvy appearance and in addition to this we also have Pazhaya Lipi (Old Script) vs Puthiya Lipi (New Script). This orthography reform is a more recent change (formalized in 1971) to accommodate the printing press, reducing the number of glyphs from about 1000 to 250. With the advent of the digital age we ended up using both the old and new scripts and even combinations of both.

The age old problem of standardization - https://xkcd.com/927/
The age old problem of standardization - https://xkcd.com/927/

Here are some of the Engineering decisions I took while making pixelshaper.

  • The aim is to not convert every possible glyph to a pixel / bitmap representation.
  • The conversion glyphs are decided by the Corpus of text we want to display in the LED badge
    • This is a small 11 x 44 display. We do not intend to display large quantities of text in this.
  • Depending on the glyphs used, we can have favorable heights to display the characters, we do not need to take a global decision on the height based on all possible combination of glyphs.
  • Reducing the glyphs to 11px height also means curves are now defined by pixels going in diagonally, which over a distance would make more sense than up close.
  • Using text-art glyphs for editing was a deliberate choice to keep the editing of the pixels (which can either be in an On state or Off State) simple and requiring nothing more than a text editor to work with.

This incidentally also drastically reduces the number of glyphs we need to hand edit manually.

Text-art representation

Claude chose to have the glyph name, the advance (how many pixels the pen moves after this glyph), and the left and top of the glyph.

We use · for a pixel turned off and for pixel turned on, and a <space> between every point to closely match the spacing of the LEDs in a display.

The below example shows k1 () extracted from Manjari Font but now in a bitmap / pixel representation.

name: k1
advance: 13
left: 0
top: 7

· · · · · ● ● ● · · · · ·
· · · · ● · · · ● · · · ·
· · · · ● ● ● ● ● ● · · ·
· · ● ● ● ● ● ● ● ● ● · ·
· ● · · ● · · · ● · · ● ·
· ● · · ● ● · ● ● · · ● ·
· · ● ● ● ● ● ● ● · ● · ·
· · · · · · · · · · · · ·

The conversion algorithm

Putting all the above together, this is what we have come up with.

%%{init: {
  "theme": "base",
  "themeVariables": {
    "background": "#101014",
    "primaryColor": "#1c1c24",
    "primaryTextColor": "#e6e6ea",
    "primaryBorderColor": "#8a6b2f",
    "lineColor": "#e8a530",
    "edgeLabelBackground": "#24241c",
    "fontFamily": "ui-monospace, monospace",
    "fontSize": "14px"
  },
  "flowchart": { "nodeSpacing": 60, "rankSpacing": 75 }
}}%%
flowchart TD
    donor(["donor.ttf<br/><i>cmap / GSUB / GPOS</i>"])
    inventory(["glyph inventory<br/><i>every conjunct, ligature and mark<br/>the corpus actually uses</i>"])
    art(["glyphs/&lt;name&gt;.txt<br/><b>●/· text-art grids — the source of truth</b><br/><i>⟳ hand-edit · rebuild · judge on hardware</i>"])
    ttf(["build/Family.ttf<br/><i>shapes identically to the donor;<br/>pixel-perfect at its native ppem (stamped)</i>"])

    donor -- "<span style='display:inline-block;border:1px solid #8a6b2f;border-radius:4px;padding:1px 6px;background:#24241c'>shape corpus (HarfBuzz)</span>" --> inventory
    inventory -- "<span style='display:inline-block;border:1px solid #8a6b2f;border-radius:4px;padding:1px 6px;background:#24241c'>rasterize + threshold<br/>at one ppem</span>" --> art
    art -- "<span style='display:inline-block;border:1px solid #8a6b2f;border-radius:4px;padding:1px 6px;background:#24241c'>pixel-square outlines;<br/>quantize to<br/>the grid</span>" --> ttf
    donor -. "<span style='display:inline-block;border:1px solid #8a6b2f;border-radius:4px;padding:1px 6px;background:#24241c'>shaping tables pass through verbatim</span>" .-> ttf
    art -- "<span style='display:inline-block;border:1px solid #8a6b2f;border-radius:4px;padding:1px 6px;background:#24241c'>⟳ hand-edit</span>" --> art

    classDef stage fill:#1c1c24,stroke:#8a6b2f,color:#e6e6ea,stroke-width:1.5px
    classDef truth fill:#2a2113,stroke:#ffb000,color:#ffd977,stroke-width:2.5px
    classDef result fill:#1c241c,stroke:#e8a530,color:#e6e6ea,stroke-width:2px
    class donor,inventory stage
    class art truth
    class ttf result

The end result is the pixelized version of the Donor font. You can see a full example output for Manjari and Nupuram conversions at the pixelshaper repository, including the text art and the output pixel font.

The examples I am using here are cherry picked, without much complexity in the glyphs. An example of a more complex glyph is when we double stack the same letter. An example of this is ശ്ശ, which is the stacked form of , and its use is similar to how ss is used in English. The place in Kerala called Thalassery is written in Malayalam as തലശ്ശേരി, and the place is also the origin of Thalassery Dum Biryani.

Stacked form ശ്ശ
Stacked form ശ്ശ

The stacked ശ്ശ was extremely mangled by the auto conversion, and had to be hand fixed to get a legible form.

name: z1z1
advance: 13
left: 0
top: 8

· ● · · · · ● ● ● ● ● · ·
· ● · · · ● · · · ● ● ● ·
· ● · · · ● · · ● · · ● ·
· ● ● · · ● · · ● · · ● ·
· · ● ● ● ● · · ● ● ● ● ·
· · · ● · · · ● ● ● ● · ·
· · · ● · · ● · · ● · ● ·
· · · · ● ● ● · · ● ● ● ·

These complex shapes are also what makes designing fonts used in Malayalam quite a non-trivial task. They change not only in width but also in height, which presents its own problems when trying to render such fonts in terminal.

Revisiting the Malayalam LED Simulator

Once I got things working in the actual LED badge, it was time I revisited the Malayalam LED Simulator and this time around I also understood why the letters become completely illegible below a certain height: without a deterministic decision on which pixel a stroke owns, the pathways become mangled.

An example from the video showing how curves are mangled when the
control points of the Bézier curves are not well understood. Further down the
video this gets fixed when the presenter explains about implied points.
An example from the video showing how curves are mangled when the control points of the Bézier curves are not well understood. Further down the video this gets fixed when the presenter explains about implied points.

In the case of a script like Malayalam, as the height gets smaller, the scaled outline coordinates land between pixels — strokes go sub-pixel — and the rounding decisions become arbitrary, mangling the curves.

Santhosh goes into nice details explaining this in his Malayalam LED Simulator’s landing page, taking the example of , and to keep continuity I am going to take the same example and extend it. The reason why the letters become illegible at 11 px height is because the ppem (Pixels Per em) is keyed to the tallest conjunct stack in the whole test corpus, which for Manjari computes to roughly 9 ppem. If we instead allow scaling based on the ink of just the text being displayed, you get a better representation of at roughly 21.5 ppem. And if there is a native pixel font built from a wider corpus sampling, we can tune where this lands — for example the conversion of Manjari to pixel resulted in a 14 ppem font.

Manjari font, from Left -> Right, Uniform, Self-scaled Outline, Native
Pixel (this does not exist in Manjari)
Manjari font, from Left -> Right, Uniform, Self-scaled Outline, Native Pixel (this does not exist in Manjari)

As you can see the existing Manjari font without much modification can be used to display simple glyphs without a lot of distortion. However you can also see the conversion is not perfect: there are double-width strokes or excess pixels turned on / off depending on the scaling. Since Native Pixel does not exist in Manjari, it is equivalent to using “Self-scaled Outline”.

Manjari Pixel font 14 ppem, from Left -> Right, Uniform, Self-scaled
Outline, Native Pixel
Manjari Pixel font 14 ppem, from Left -> Right, Uniform, Self-scaled Outline, Native Pixel

With the pixel variation of Manjari, under “Uniform” it still suffers the same issue, but with “Self-scaled Outline” suddenly we have far more excess pixels, because now the 14 ppem font got blown up to 21.5 ppem. Under “Native Pixel” you can see the glyph is equivalent to the pixel art shown above.

The self scaling property also made me create a sibling program called font2badge, which skips all the messy hand edit part and approximates the best possible fit for the exact text being rendered, and can usually display the text without much issue. Using font2badge I was able to do a direct conversion of text using the Ishtika font.

Finally looks blocky enough and a good fit for LED badge
Finally looks blocky enough and a good fit for LED badge

And for more complex cases you would want to use pixelshaper and handtune the glyphs.

In the middle image, I reduced the 21 ppem a bit so that the
approximation can fit the width of the display.
In the middle image, I reduced the 21 ppem a bit so that the approximation can fit the width of the display.

Perhaps someone with more pixel art skills than me can put the dots in the right place to make it look better.

Extending beyond Malayalam fonts and more

Even though pixelshaper was designed with Malayalam as the primary driver, it is also quite compatible with other Indic languages and also Farsi / Arabic scripts.

Here is the representation of Santhosh (संतोष in Hindi) and (سانتوش in Farsi / Arabic).

The diacritics need hand tuning.
The diacritics need hand tuning.

This is rendered using Mukta Devanagari.

Arabic / Farsi also renders well when it is simple.
Arabic / Farsi also renders well when it is simple.

This one uses Vazirmatn.

I also spent a bit of time making img2badge, which can take an arbitrary image and convert it into a 1-bit png (with the same editable text-art form as an intermediate). You can check out some of the examples I have used to generate tiny logos that fit in the LED badge.

Epilogue

This whole silly adventure started with me trying to display my name in Malayalam in a tiny LED badge. Little did I know it would lead me down so many rabbit holes.

Special thanks to Anthropic and their OSS Claude license, that allowed me to use their Fable 5 to solve this problem.

My main takeaway from this exercise has been how one uses LLMs to solve problems where they have little or no knowledge in that domain. I still do not understand the universe of Typography and Fonts, or how they work in depth, but LLMs fed me enough information that I can take an informed decision on things. The experience went from using an LLM without any knowledge of the problem space resulting in Garbage In, Garbage Out, to using the LLM to gain some understanding of the problem, to coming up with a somewhat working solution. LLMs are quite powerful tools, and even though they are just stochastic parrots, one should not outright dismiss what they can be capable of. And as with any tool it comes with its own set of nuances. I am not going to dive into the aspects of copyright issues, generated slop code etc. — at the end of the day any tool can be put to good or bad use and it depends on the user using it and their intentions. LLMs are here to stay whether we like it or not and learning to use the tool is entirely up to you. Artisanally hand-rolled gluten-free code might become a thing of the past, a leisure time activity, but mainstream is going to be more and more LLM assisted code and it will get into more niche areas as the technology matures.

All those moments will be lost in time, like tears in rain ...
All those moments will be lost in time, like tears in rain ...

As for Typography and Fonts, my understanding is still limited. I do not know who would be interested in these silly tools I built with Claude, but they are out there in the open for anyone or anything to pick up and use. I satisfied a long standing itch with this — something that, AFAIK, no expert could directly help me out with, and to me this is the game changer: on the contrary, I was able to identify the problem and contribute a more accurate simulation to the Malayalam LED Simulator. Perhaps in time these tools will also be forgotten and fade away, like tears in rain …