On a daily basis, I work on firmware for an embedded device that uses the Bridgetek FT800. It’s a nifty chip that takes commands over SPI/I2C and turns them into an image displayed on an LCD. It’s very useful for displaying user interfaces with simple microcontrollers. Bridgetek is actually a spinoff company from FTDI, and this kind of solution seems right up their alley — take something complicated like USB or a display controller, and create a simpler interface for dealing with it, such as UART/SPI/I2C.

One thing that’s usually important about user interfaces is the ability to display text. The FT800 has a very basic capability for handling fonts. It’s not really much more than the ability to deal with sets of 127 sprites that each comprise a “font”. As a developer, if you want to use fonts aside from the (very limited) stock ones that come bundled in the FT800’s ROM, you have to create bitmap images that you upload into the 256 KB of available display RAM.

Several years ago, we had to deal with converting the user interface to display in a bunch of different languages, including Chinese. Most of the new languages we added weren’t a big problem, because we could just create a couple of fonts containing all of the special accented characters we needed and be done with it. Chinese, though, was a bigger challenge. There are so many different characters. Putting every character for every string into the limited display RAM is impossible. My coworker at the time came up with a clever script that automatically rasterized the font glyphs and created groups of different 127-character fonts for each displayed screen in the user interface. Every time you changed screens, the new set of fonts for that screen would be loaded into the display RAM.

This solution resulted in a lot of duplicated characters in different fonts, which wasted space in flash memory, but it had some smarts to try to minimize the number of Chinese fonts that needed to be created. It’s not the point of this blog post, but I’d highly recommend using something more full-fledged and able to work directly with Unicode fonts if you’re going to have that level of internationalization in a product. I feel like we really pushed the FT800 to its limit on that project.

Anyway, the script is great, and still works fine to this day, except for one small annoyance: it runs pretty slowly, even on very fast development computers. The slowdown has nothing to do with any of the logic involved with figuring out the font grouping. Instead, the problem is silly: in order to rasterize each screen’s set of fonts, it runs a Windows-based font converter utility that was provided by FTDI several years ago (fnt_cvt.exe). With all of our developers using Linux, it isn’t possible to run natively. A Linux build of it isn’t available, so the script just calls it using Wine. It gets called many times during the script, and there’s a significant overhead cost involved in running a program in Wine over and over again.

I recently decided that I would take a look at fnt_cvt.exe to understand what it does, in hopes of porting it to native Linux and speeding it up. It didn’t take me long to realize that it is actually a Python script that has been packaged with PyInstaller! I was able to extract its contents with pyinstxtractor, and gained access to the original Python script.

This seemed very promising! Looking at the extracted script, I could see that it uses the Python Imaging Library (PIL) to render the fonts. I tried running the script natively on Linux, and it worked. It ran twice as fast as Wine. I thought I had magically fixed everything — until I realized that it was spitting out different results than the original fnt_cvt.exe. The rendered glyphs were sized differently, and this completely changed what the UI looked like on the product. I didn’t want to change the appearance at all. I wanted the exact same results.

My first guess, without doing any research, was maybe it had something to do with DPI, or maybe Wine was doing something weird with font rendering. I quickly determined those were both not the problem. PIL doesn’t worry about DPI when rendering fonts as far as I can tell, and running the .exe on an actual Windows computer results in identical output to running it with Wine.

After further digging in the extracted PyInstaller contents, I found a bunch of stuff related to PIL. I couldn’t figure out if the bundled PIL was the original PIL or the modern Pillow fork. One interesting file in particular was called PIL._imagingft.pyd, which was actually a DLL containing compiled code for PIL’s imagingft module. It became obvious when looking at the strings in this file that it also had a self-contained build of FreeType.

Luckily, FreeType is good about making its version number available at runtime through an API, so I was able to do some reverse engineering to determine that it was using FreeType 2.3.9. My system has FreeType 2.10.1, so that’s a difference.

It’s understandable that different versions of FreeType will render fonts differently — after all, it’s a library for rendering fonts and it receives improvements over time. With that in mind, I manually built FreeType 2.3.9, along with the most recent Pillow library. This actually required a small patch to Pillow’s imagingft code due to FT_Bitmap_New being renamed to FT_Bitmap_Init sometime after version 2.3.9.

I ran the script again, modifying my LD_LIBRARY_PATH to ensure that it picked up my manually-built FreeType rather than my system’s FreeType. The output was still different from Wine’s output! ldd and strace both confirmed to me that it was indeed using my custom FreeType 2.3.9 build, and I also noticed that the new output using FreeType 2.3.9 differed from the output that my first run of the script on Linux had produced. So now I had three different outputs from three different sources: the original exe containing FreeType 2.3.9 with mystery PIL, my system’s FreeType with my system’s Pillow, and FreeType 2.3.9 with the latest Pillow.

At this point the next logical thing to do was to tinker with different versions of the Pillow library. After fighting to get it to compile, I figured out that Pillow 2.0.0 combined with FreeType 2.3.9 actually gave me identical results to the original Windows binary. Pillow 2.1.0 differed, so something between 2.0.0 and 2.1.0 changed the font rendering logic.

There’s probably a way that I could create a venv to ensure I use the exact combination of versions that I need, but at this point I actually feel more comfortable creating a small C program, statically linked, that uses FreeType 2.3.9 and libunistring. It will borrow the exact same rendering logic used in Pillow 2.0.0. That way, I won’t have to worry about future compatibility issues as time goes on and distros get updated.

I actually had a little bit of fun with this. I set up a script to automate testing against the final releases of FreeType from 2.2.0 to 2.11.1, calling it in the same way that Pillow 2.0.0 uses it. That represents 15 years of FreeType from 2006 to 2021. What I found is that between those two versions, there were 16 different rendering results that I observed. The most recent time the rendering changed was with version 2.9 in 2018. The results I needed to see from FreeType 2.3.9 were consistent from 2.3.5 to 2.3.11. Building Pillow, especially with older versions, is a little more tricky, so I didn’t perform the same analysis on Pillow.

I guess you could say this blog post is more of a PSA than anything. If you’re doing font rendering in Python using PIL, don’t expect it to be consistent across different computers, OS versions, distros, etc. FreeType and Pillow both change their rendering algorithms from time to time. If you want guaranteed consistent font rendering, you should probably focus on sticking with a particular version of FreeType (and Pillow, if you’re using Python).

I was originally annoyed that fnt_cvt.exe wasn’t provided as a Python script, but it’s kind of a good thing at the same time — it ensures that regardless of what computer you run it on, you get identical results from it. I doubt that was Bridgetek/FTDI’s actual reason for only providing it as an exe, but at least it resulted in consistent rendering for everyone who used it.

As a final point to make, isn’t FreeType pretty amazing? As long as I stick with a specific version, I can render fonts consistently on any supported platform. I also tried version 2.3.9 on macOS and the result matches Windows and Linux. Font rendering is a complicated problem, and FreeType takes care of all of it. I want to give credit where credit is due!

Trackback

1 comment

  1. […] Fun with font rendering consistency in Python […]

Add your comment now