Font Range Detection with Noto

Products, display names, and user generated content rarely fits in the 94 displayable characters in the ASCII table for a world wide audience. While you've migrated storage of such content to UTF-8 or Punycode as a hack, actually displaying it is another matter.

Support for multiple fonts

Microsoft microsoft, Apple apple, and Google google implement font substitution in their text rendering APIs so that when you write "Hello 世界" with only Arial.ttf loaded, the other characters are drawn nearby as if nothing unusual occurred. Though, the styling of the fonts may clash. That is better than seeing tofu □□□□□ □□□ □□□□□□□!

These three microsoftapplegoogle also maintain the most impactful software in history: the web browser chrome & safari. As long as the encoding is understood on both sides, you can be sure that glyphs will render with a fallback when the woff used on the page is lacks a grapheme (e => 0x65) or grapheme cluster (🇺🇸 => 0xf09f 87ba + 0xf09f 87b8). Whether a customer's name is bolded in a div or drawn text in a canvas, font substitution is there to preserve the consistent delivery of symbols across languages, cultures, and time periods.

What if a symbol is not available in the primary font and you have an Ideal second font for other glyphs? Merging all possible fonts to cover every grapheme that could be seen is not reasonable. Browsers give the option to style multiple font families at once for this reason. Coupled with unicode-range, the browser will only download the font files necessary to render the page and fallback to system font substitution when the available fonts cannot render the remaining graphemes. Noto Color Emoji breaks the web font up to nine or so files on the web. When the US flag grapheme cluster is rendered, the browser does not need the font data for a frog or a coffee.

https://fonts.googleapis.com/css2?family=Noto+Color+Emojicss

Font splitting and substitution allow native and web applications to use memory and bandwidth resources more efficiently. For this to work, the font engine necessarily needs to support multiple fonts at once to correctly implement a consistent baseline, along with many other details that only font authors and font engine creators have an interest in.

Without Font Substitution

Rendering multilingual text gets a lot harder when you’re outside the safety net of native OS rendered applications and web browsers. Think game engines, embedded systems, or serverless wasm workloads like @levischuck/render-html. If you're lucky like me with satori, the tools you use will natively support multiple fonts. If you're unlucky and the tool you use merely wraps the FreeType API like the Pillow ImageFont module, then you'll have to segment text and perform baseline alignment and line wrapping yourself, or integrate with a library like libraqm.

Assuming that you have a solution to sequence multiple fonts while looking great 🌟, the next issue is knowing which fonts to load into memory before the expensive task of fetching fonts from disk or over the network.

Take a little inspiration from the CSS @font-faceat-rule and its unicode-range property and we can design a small schema that can can help us identify what fonts to load by iterating over each codepoint.

Here's what Google serves for Noto Sans Thai:

https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@100..900&display=swapcss

You'll see several individual codepoints and a few ranges in these CSS responses. However, repeating this verbatim with many fonts loaded may surprise you later. U+02D7 is "Modifier Letter Minus Sign." How many other fonts do you suppose has the same exact symbol?

https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swapcss

Just about every Noto font contains a duplicate of the latin and latin-ext glyph set, and so each has U+02D7. Any loader you prepare will need to ensure not to accidentally pull every font available merely because the data you have available contains overlapping codepoints.

Deduplicating codepoint ranges

For every language you plan to support (and more if you care about Linear B), download them and reference their static regular TTF files with a script like this. FontTools in python is the best casual font tool chain you could ask for.

ranges.pypython

This script outputs the same structure as what I export in my library @levischuck/tiny-font-ranges. As described before, we see that inside each TTF file, the Latin set 0020-007E and plenty more are embedded. You can reduce each font file for performance if you need to after the dedupe stage with a technique like I show in Converting Fonts to WOFF2 for the web. Though I find this step to be unnecessary at my scale.

examplebash

Once you have a set of fonts mapped with data like so, is relatively straight forward to find overlaps and to restructure all ranges without said overlaps.

symbol.tstypescript

Removing overlaps introduces a sharp edge. If your intention is to support a proper / strict subset of these fonts, and these specific conditions...

  • A codepoint (like ⪔) is in your data that which is not covered in the subset of selected font ranges
  • It was supported in the full set of ranges so any overlaps are omitted in the mapping data
  • It technically exists in a font that is in the subset of fonts, but because it overlaps with the font that was removed

Then you may find that the glyph (e.g. ⪔) sometimes renders and sometimes doesn't depending on whether the another font is pulled in which happens to contain that glyph by coincidence.

Determining which fonts to fetch and use

Once all your fonts are mapped, the ranges they support are recorded, and a way to draw text with multiple fonts (left as an exercise to the reader) is accessible, all that remains is looping over the input codepoints to find a matching font.

example.tstypescript

From there, it's a matter of importing the fonts by name and adding it to the font-family at the root of the DOM — if you have one 😉.

At the time of publication, the scan algorithm in @levischuck/tiny-font-ranges is simple to understand but naïve. A faster lookup structure would be a Trie with a state on which leaves have already been visited.

Footnotes

  1. Chrome (and Chrome derivatives like Edge) makes up 83% of the market share, while Safari takes nearly 15%. Firefox firefox counts too... at 2.2% (source: statcounter ).
  2. For geo-political reasons, the font available on Windows Segoe UI Emoji does not include country maps. Your ability to see it on this blog with Windows is possible because I added Noto Color Emoji.
  3. Rendering emojis next to ascii sequences in Pillow is terribly frustrating. Once you experience this frustration, you'll appreciate an emoji once in a while (when and only when it is written by a person, not by ChatGPT).
  4. This algorithm is O(n^2), which isn't ideal for anything in real-time. It is sufficient for local execution when adding more fonts to the pool, though.