Maps of India made with MapLibre GL were so far always only showing English labels, because MapLibre has not been capable of handling many of the beautiful writing systems used in South and South-East Asia. The MapLibre GL Complex Text plugin now allows to render the following 11 previously unsupported scripts: Bengali, Devanagari, Gujarati, Gurmukhi, Kannada, Khmer, Malayalam, Myanmar, Oriya, Tamil, and Telugu.
In this text we will look into some basics of text shaping, learn about MapLibre's limitations, and discuss how the complex text plugin works around them.
The full source code as well as a live demo can be found at https://github.com/wipfli/maplibre-gl-complex-text.
Let us start with text shaping. It is a process which takes a Unicode string and a font as input and returns as output a list of print instructions.
At a high level, a font contains glyphs and rules. The glyphs can be thought of as outlines of characters, punctuation, u.s.w. and they are stored in a vector format similar to SVG. Each glyph in the font file has a unique index. Rules on the other hand define how input Unicode strings get mapped to glyphs and how the glyphs should be positioned vertically and horizontally. Note that only displacements are commonly used and glyphs do not get stretched or rotated.
Shaping now takes a Unicode string and a font, applies the rules to the input text, and returns print instructions in the form of a glyph vector, i.e., a list positioned glyphs.
To understand what that means let us consider the following example in Hindi. The input text is "दिल्ली", i.e., Delhi in Hindi which gets pronounced as Dillī with a long i at the end. And let us use the font NotoSansDevanagari-Regular.ttf from Google's Noto font stack.
We give the text and the font file to HarfBuzz, an open-source text shaping engine, using their convenient command-line tool hb-shape. From this we get the following glyph vector:
HarfBuzz' print instructions are:
Take glyph with index g=606, print it with offset (dx, dy)=(0,0), advance by (ax, ay)=(259, 0).
Then, take glyph with index g=42, print it with offset...
A positioned glyph is simply a glyph index together with offset and advance values. Positioned glyphs in addition possess a cluster index which is relevant for cursor movements but in map rendering there is no cursor and we can therefore skip the cluster index.
The example above shows that:
1) The length of the input text does not have to match the length of the output glyph vector. The input text has 6 characters but one of them, the virama, does not map to an output glyph.
2) There is some reordering taking place. The letter ि (i) is placed before the letter द (d) even though it is pronounced after.
3) The two ल (l) are not rendered identically; the first results in glyph g=210 and the second in g=54.
This example gives a feeling of the interesting processes needed to render Devanagari, the script used to write Hindi. Devanagari, as well as other S and SE Asian scripts are therefore sometimes called complex scripts.
MapLibre's text rendering system has only rudimentary text shaping capabilities. Given a Unicode character, MapLibre always uses the same positioned glyph.
The limitations of MapLibre have been described in more detail in https://oliverwipfli.ch/about-text-rendering-in-maplibre-2023-10-17/, but to come back to our previous Delhi example, MapLibre would probably show something like "द ि ल ् ल ी" instead of "दिल्ली".
The text would look broken:
1) The hidden virama (्) is shown.
2) No reordering is applied to द (d) and ि (i).
3) The two ल (l) look identical.
Note that for scripts like Latin, Greek, or Cyrillic it is very much fine to render always the same positioned glyph given some input Unicode character. One could lift this limitation of MapLibre by integrating a shaping engine like HarfBuzz into the core of MapLibre. This would probably be the right thing to do. Or one could accept this limitation and work around it, which is what the positioned glyph font approach does.
To introduce the idea behind a positioned glyph font, let us consider a simple example, the Hindi word "दिल्ली". For this we create the following font, a positioned glyph font, which maps input Unicode characters to positioned glyphs of NotoSansDevanagari-Regular.ttf in the following way:
where the tuple corresponds to (g, dx, dy, ax, ay), i.e., glyph index g, offsets dx and dy, and advanced values ax and ay.
If we now give MapLibre the above font and the string "ABCDE", it will display "दिल्ली". Magic, MapLibre can now render Delhi correctly.
But what happens if the input text is not known? Can we make a font with all positioned glyphs needed to render all Hindi words? It turns out the answer is yes and the steps needed were the following:
First, we assembled a word corpus with as many Hindi words as possible. For this we downloaded the entire Wikipedia in all languages, WikiData, and OpenStreetMap. We extracted all text that uses the Devanagari script, and made a list of unique strings - the word corpus. The Devanagari word corpus turned out to be almost 100 megabytes large. You can have a closer look here: https://github.com/wipfli/word-corpus.
Second, we shaped all words in the Devanagari corpus using HarfBuzz and the font NotoSansDevanagari-Regular.ttf. During this we aggregated statistics of how often which positioned glyph appears. In total there are 900 unique positioned glyphs needed for Devanagari. Note that MapLibre's text positioning system uses a font size of 24 with integer offset and advance values, which means one can downsample the HarfBuzz output by a factor of 64 which in turn reduces the explored parameter space.
Now that we know which positioned glyphs are actually needed to render Hindi, we order them by frequency and assign Unicode characters to them, thereby forming a positioned glyph font for all Hindi text. Since letters like A, B, and C should still be available on our maps, we instead take characters from the Private Use Area PUA, a special range in Unicode that is reserved for client-specific applications like ours. We call the mapping from Unicode character to positioned glyph an "encoding". The code for this is available here: https://github.com/wipfli/pgf-encoding
Third, and finally, we take the encoding and NotoSansDevanagari-Regular.ttf and turn it into glyph ranges - the font format that MapLibre is expecting as input. This is done with a modified version of maplibre/font-maker, see https://github.com/wipfli/pgf-glyph-ranges.
We repeated the above work with Bengali, Gujarati, Gurmukhi, u.s.w. and created word corpuses, encodings, and positioned glyph font ranges for each script. We always used fonts from Google Noto and made sure that encodings are collision-free between scripts. The Private Use Area turned out to be too small to accomodate all positioned glyphs, see table, so that we also included Unicode ranges of Bengali, Devanagari, u.s.w. as well as some Braille characters and math symbols which are rarely used in maps.
Script | Corpus | Glyphs |
Bengali | 32 MB | 605 |
Devanagari | 95 MB | 900 |
Gujarati | 3 MB | 557 |
Gurmukhi | 3 MB | 211 |
Kannada | 2 MB | 541 |
Khmer | 12 MB | 858 |
Malayalam | 24 MB | 244 |
Myanmar | 28 MB | 829 |
Oriya | 2 MB | 894 |
Tamil | 27 MB | 277 |
Telugu | 27 MB | 1755 |
Total | 255 MB | 7671 |
Although it is not part of this plugin, we also made the same analysis with Arabic and a Nastaliq font which is maybe one of the most challenging shaping tasks. Using NotoNastaliqUrdu-Regular.ttf we found 25,023 unique positioned glyphs.
Now that we have a way to render complex text in MapLibre with positioned glyph fonts, what is left is the conversion of standard Unicode text to the encoding used by the positioned glyph font. This is what the MapLibre GL Complex Text Plugin does.
The plugin uses harfbuzzjs which is the WebAssembly/JavaScript version of HarfBuzz. HarfBuzz itself is a C++ text shaping engine.
To support right-to-left (RTL) scripts such as Arabic and Hebrew, MapLibre GL uses a WebAssembly port of the ICU library. It performs bidirectional text analysis and replaces Arabic characters with their presentation forms. For this the user can register a callback which is called with every string that will be rendered on the map. It takes text as input and returns text as output. The complex text plugin makes use of the RTL plugin hook and uses exactly the same interface. Since only one RTL plugin can be registered, the complex text plugin is mutually exclusive with the RTL plugin. But the RTL functionality could be integrated in the complex text plugin if needed.
The complex text plugin does the following:
First, the input string is broken into what are called script runs, i.e., strings of constant script. Unicode defines for every character the script it belongs to. Some characters are shared between scripts such as for example a whitespace. Those are assigned to the COMMON script. Other special scripts are INHERITED and UNKNOWN. Unicode does not define an algorithm for how these three special scripts should be treated during script itemization and we first had a bug in the complex text plugin where we broke Malayalam strings at zero-width-joiners. The South Indian scripts Malayalam, Kannada, Telugu, and Tamil all make use of zero width joiners and non-joiners. Ignoring those results in broken text.
Second, we iterate over the segments of the input text and shape them with HarfBuzz using exactly the same Noto fonts as were used for the positioned glyph fonts. This results in glyph vectors consisting of positioned glyphs.
Third, for every positioned glyph we look up which codepoint it maps to and use this codepoint in the resulting output text string.
The plugin returns the encoded string to MapLibre, MapLibre uses the positioned glyph font and renders everything correctly. This all takes place in the browser. Alternatively, one can also perform the above steps at tile generation time on the server.
The complex text plugin comes with several limitations. We will discuss them below while keeping in mind that this plugin allows for the first time to render text in MapLibre in the native languages of around 1.5 to 2 billion people.
Fonts cannot be changed on the fly. If one would like to replace Noto with some other font stack one needs to run HarfBuzz on the script corpuses again and compile new encodings and positioned glyph fonts specific to the new font. This process takes a few CPU hours.
There is no way to have different font weights such as regular, medium, or bold. This is due to the fact that the plugin chooses the font purely based on the script.
Adding more scripts is in principle possible, but one needs to sacrifice some more Unicode ranges for the encoding. Adding Urdu Nastaliq support for example would mean that we need an additional 25k codepoints for the encoding, at which point one would probably need to use some Chinese-Japanese-Korean (CJK) codepoints.
Right-to-left support for Hebrew, Arabic, u.s.w. is currently blocked because the complex text plugin uses the RTL hook.
One could improve the complex text plugin here or there, but what would probably be much more meaningful would be to redesign MapLibre's text rendering system.
Text rendering in general is quite involved but when we look at maps only, some parts can be skipped. For example paragraph breaking is less relevant or cursor interactions are fully absent.
A redesigned text rendering engine for MapLibre could probably have a small API surface similar to the RTL plugin and it could be written in C++ and be used for both MapLibre Native and GL JS. Alan from Grab already laid out what changes are necessary across the code base of MapLibre Native to support HarfBuzz. Also discussions were held about how the style specification should be modified.
It would be quite interesting to think and work more in this direction. Text is maybe the most important visual element on a map as it conveys most information and has a large impact aesthetically.