Monochrome font rendering with FreeType and Python
For my Raspberry Pi internet radio project I needed a way to render text suitable for a low resolution monochrome LCD. This article describes how to render 1-bit text using FreeType and Python.
What we’re going to do
I’ve structured this tutorial into four main sections. First, there’ll be a brief introduction to the FreeType font rendering library. Second, we’ll attempt to render bitmap images of single characters. Third, we expand the previous functionality to render strings of multiple characters. Fourth, you’ll learn how to add support for kerning in order to improve the visual quality of your font rendering. The image above shows what results to expect from this tutorial.
At the end of the article you’ll also find the full example code for download.
Update: What it looks like on a real display
Some people have asked for images of the font rendering code being used with a real LCD. The above picture shows an earlier version of the code running on a Raspberry Pi Model B connected to the “Raspi-LCD” board by Emsystech Engineering. The board contains a backlit 128 × 64 pixels display and five buttons. It comes with a C library that I use from Python with the ctypes
module. The board is high quality and the haptics of the buttons are very good as well (they’re very clicky). I recommend it very much.
The FreeType library
FreeType is a popular open source C library for rendering fonts. Apparently more than a billion consumer devices with graphical display use FreeType to display text. The widespread use and high-quality output make the library an ideal choice for rendering text. FreeType works with the most common font formats like TrueType (.ttf files) and OpenType (.otf files).
For using FreeType with Python I recommend freetype-py by Nicolas Rougier which provides Python bindings for FreeType 2.
Rendering single characters
The first thing we want to achieve is to render monochromatic images for single characters. Once we can do that it’ll be reasonably simple to extend our code to display strings with multiple characters. To generate a bitmap image representation for a single character (glyph) with FreeType we need to do the following:
- Load the font file.
- Get the glyph bitmap for the given character.
- Unpack the glyph bitmap into a more convenient format.
After this we’re able to render monochrome bitmaps for single characters. For example, the character e would look like this:
We’re going to work on this list from top to bottom and start by defining a class Font
that represents a fixed-size font as loaded from a file on disk:
class Font(object): def __init__(self, filename, size): self.face = freetype.Face(filename) self.face.set_pixel_sizes(0, size) def glyph_for_character(self, char): # Let FreeType load the glyph for the given character and # tell it to render a monochromatic bitmap representation. self.face.load_char(char, freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO) return Glyph.from_glyphslot(self.face.glyph) def render_character(self, char): glyph = self.glyph_for_character(char) return glyph.bitmap
We’ve used a yet undefined class called Glyph
in the glyph_for_character()
method. The Glyph
class is our wrapper around FreeType’s glyph representations and primarily helps with unpacking FreeType’s bitmap format for monochrome glyphs. FreeType stores monochrome bitmaps in a packed format where multiple pixels are encoded within a single byte. This format is slightly inconvenient to use because it involves some bit-fiddling.
To give an example on how to access individual pixels in this format we’re going to unpack the glyph bitmap into a Python bytearray
. In this unpacked format each pixel is represented by a single byte. A value of 0
means that the pixel is off and any other value means that it is on. The Glyph
class with the bitmap unpacking code looks as follows:
class Glyph(object): def __init__(self, pixels, width, height): self.bitmap = Bitmap(width, height, pixels) @staticmethod def from_glyphslot(slot): """Construct and return a Glyph object from a FreeType GlyphSlot.""" pixels = Glyph.unpack_mono_bitmap(slot.bitmap) width, height = slot.bitmap.width, slot.bitmap.rows return Glyph(pixels, width, height) @staticmethod def unpack_mono_bitmap(bitmap): """ Unpack a freetype FT_LOAD_TARGET_MONO glyph bitmap into a bytearray where each pixel is represented by a single byte. """ # Allocate a bytearray of sufficient size to hold the glyph bitmap. data = bytearray(bitmap.rows * bitmap.width) # Iterate over every byte in the glyph bitmap. Note that we're not # iterating over every pixel in the resulting unpacked bitmap -- # we're iterating over the packed bytes in the input bitmap. for y in range(bitmap.rows): for byte_index in range(bitmap.pitch): # Read the byte that contains the packed pixel data. byte_value = bitmap.buffer[y * bitmap.pitch + byte_index] # We've processed this many bits (=pixels) so far. This determines # where we'll read the next batch of pixels from. num_bits_done = byte_index * 8 # Pre-compute where to write the pixels that we're going # to unpack from the current byte in the glyph bitmap. rowstart = y * bitmap.width + byte_index * 8 # Iterate over every bit (=pixel) that's still a part of the # output bitmap. Sometimes we're only unpacking a fraction of a byte # because glyphs may not always fit on a byte boundary. So we make sure # to stop if we unpack past the current row of pixels. for bit_index in range(min(8, bitmap.width - num_bits_done)): # Unpack the next pixel from the current glyph byte. bit = byte_value & (1 << (7 - bit_index)) # Write the pixel to the output bytearray. We ensure that `off` # pixels have a value of 0 and `on` pixels have a value of 1. data[rowstart + bit_index] = 1 if bit else 0 return data
Clearly, the most important parts of Glyph
class are in the bitmap unpacking code. Once we’re rendering multi-character strings we’ll extend the class with additional metadata, such as the advance width that tells us the horizontal distance between glyphs.
The final part that’s missing is the Bitmap
class. It’s a simple helper class for working with bytearray
-based bitmaps:
class Bitmap(object): """ A 2D bitmap image represented as a list of byte values. Each byte indicates the state of a single pixel in the bitmap. A value of 0 indicates that the pixel is `off` and any other value indicates that it is `on`. """ def __init__(self, width, height, pixels=None): self.width = width self.height = height self.pixels = pixels or bytearray(width * height) def __repr__(self): """Return a string representation of the bitmap's pixels.""" rows = '' for y in range(self.height): for x in range(self.width): rows += '*' if self.pixels[y * self.width + x] else ' ' rows += '\n' return rows
The class allows us to quickly experiment with font rendering in the Python REPL. Calling repr()
on a Bitmap
object returns a textual representation of the 2D image encoded in the bitmap. This is going to be very helpful when we start debugging our font rendering code. Next, let’s actually try to render a single glyph bitmap:
>>> fnt = Font("helvetica.ttf", 24) >>> ch = fnt.render_character("e") >>> repr(ch) ***** ******* *** *** *** ** ** ** *********** *********** ** ** ** ** ** ******** *****
Great, that means our glyph rendering code works. The most complicated thing here was the bitmap unpacking code. We now continue with rendering strings with multiple characters.
Rendering multiple characters
Now that we know how to render single character glyphs we’re going to extend that functionality into rendering strings with several characters. The critical part here is glyph placement, that is, ensuring that all characters line up correctly. To render multi-character strings we make the following changes to the existing code:
- Extend the
Glyph
class with additional metadata that tells us how characters are placed next to each other (advance width, top-side bearing, ascent, and descent). - Implement a two pass algorithm for rendering strings:
- Pass 1: Compute the dimensions of the bitmap for a given string.
- Pass 2: Successively draw the glyph for each character into an output bitmap.
Once we’ve completed these steps we’ll be able to render strings such as this one:
We start with extending the Glyph
class with fields for the glyph’s advance width, top-side bearing, ascent, and descent. I’ll briefly explain the purpose of these fields before we continue. If you want to learn more about these glyph metrics take a look at the FreeType documentation.
The advance width tells us where to place the next character horizontally, that is, how many pixels we move to the right (or to the left) to draw the next glyph.
The ascent, descent, and the top-side bearing determine the vertical placement of the glyph. To understand vertical glyph placement the concept of the baseline is very important. The baseline is defined to be the line upon which most letters sit. The ascent and descent determine how the glyph should be placed relative to the baseline.
In western typography most letters extend above the baseline. We say that they have a positive ascent. Some letters, such as g, extend below the baseline. This means that both their ascent and descent are positive. Of course, other mixtures are also possible, for example, there may be letters with an ascent of zero but a positive descent, and so on.
The top-side bearing is the vertical distance from the glyph’s baseline to its bitmap’s top-most scanline. We need this value to compute the glyph’s ascent and descent.
While these glyph metrics seem straightforward to compute, it took me a few tries and some pencil drawing to get them right. The updated version of the Glyph
class with added metrics looks like this:
class Glyph(object): def __init__(self, pixels, width, height, top, advance_width): self.bitmap = Bitmap(width, height, pixels) # The glyph bitmap's top-side bearing, i.e. the vertical distance from the # baseline to the bitmap's top-most scanline. self.top = top # Ascent and descent determine how many pixels the glyph extends # above or below the baseline. self.descent = max(0, self.height - self.top) self.ascent = max(0, max(self.top, self.height) - self.descent) # The advance width determines where to place the next character # horizontally, that is, how many pixels we move to the right # to draw the next glyph. self.advance_width = advance_width @property def width(self): return self.bitmap.width @property def height(self): return self.bitmap.height
Next, we’re going to work on the Font
class and extend it with a two-pass algorithm for rendering multi-character strings.
The first pass computes the space occupied by the given string, that is, the dimensions of the given text as if it were rendered into a bitmap. Besides the width and height of the resulting bitmap in pixels, we also need to know the position of the baseline for correct vertical glyph placement.
We compute the overall width by summing up the advance widths for all glyphs. The overall height is determined by the maximum ascent and descent. The baseline of a multi-character string equals the maximum descent of all glyphs within1 the string.
The resulting function text_dimensions()
looks as follows:
class Font(object): def text_dimensions(self, text): """ Return (width, height, baseline) of `text` rendered in the current font. """ width = 0 max_ascent = 0 max_descent = 0 previous_char = None # For each character in the text string we get the glyph # and update the overall dimensions of the resulting bitmap. for char in text: glyph = self.glyph_for_character(char) max_ascent = max(max_ascent, glyph.ascent) max_descent = max(max_descent, glyph.descent) width += glyph.advance_width previous_char = char height = max_ascent + max_descent return (width, height, max_descent)
The second pass successively draws the glyph images into an output Bitmap
. For the second pass we must know the text dimensions in order to allocate a bitmap of sufficient size and to correctly place each character vertically.
You can see the render_text()
function that performs the second pass here:
class Font(object): def render_text(self, text, width=None, height=None, baseline=None): """ Render the given `text` into a Bitmap and return it. If `width`, `height`, and `baseline` are not specified they are computed using the `text_dimensions' method. """ if None in (width, height, baseline): width, height, baseline = self.text_dimensions(text) x = 0 previous_char = None outbuffer = Bitmap(width, height) for char in text: glyph = self.glyph_for_character(char) y = height - glyph.ascent - baseline outbuffer.bitblt(glyph.bitmap, x, y) x += glyph.advance_width previous_char = char return outbuffer
Drawing characters into the outbuffer
bitmap is done by Bitmap.bitblit()
. It performs a bit blit operation to copy pixels from one bitmap into another:
class Bitmap(object): def bitblt(self, src, x, y): """Copy all pixels from `src` into this bitmap, starting at (`x`, `y`).""" srcpixel = 0 dstpixel = y * self.width + x row_offset = self.width - src.width for sy in range(src.height): for sx in range(src.width): self.pixels[dstpixel] = src.pixels[srcpixel] srcpixel += 1 dstpixel += 1 dstpixel += row_offset
Using the new code we’re able to render our first multi-character string:
>>> fnt = Font("helvetica.ttf", 24) >>> txt = fnt.render_text("hello") >>> repr(txt) ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ***** ***** ** ** ****** ********* ******* ** ** ******** **** *** *** *** ** ** *** *** *** ** *** ** ** ** *** *** ** ** ** ** ** ** ** ** ** ** *********** ** ** ** ** ** ** *********** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** *** *** ** ** ** ** ** ** *** *** ** ** ******** ** ** ******** ** ** ***** ** ** ******
Great, this is starting to look useful. The tricky parts in this section were handling the advance width and vertical glyph placement correctly. So, be sure to also try some combinations of characters that descent below the baseline. For example, the string “greetings, world” should render correctly with parts of the g and the comma descending below the baseline.
Adding kerning support
Kerning adjusts the horizontal space between glyphs to achieve visually pleasing typography. A typical example where kerning leads to a more pleasing result is the letter pair AV. With kerning the bounding boxes of both letters overlap slightly to prevent superfluous horizontal space. In the following picture the first line was rendered without kerning and the second line was rendered with kerning:
As you can see, kerning is a visual optimization – it’s not mandatory but can make quite a difference in the quality of your text rendering. For displaying text on a 128 × 64 pixels monochrome display it’s probably overkill to implement kerning2. But with FreeType it’s reasonably simple to add kerning support so let’s go ahead with it anyways.
To add kerning to our existing codebase we need to make three changes:
- Add a way to access kerning information for a character pair.
- Take kerning information into account during multi-character rendering.
- Fix a small visual artefact in the glyph drawing code.
So we start by extending the Font
class with the following function that returns the kerning offset for a character pair, that is, two characters that are to be drawn in sequence:
class Font(object): def kerning_offset(self, previous_char, char): """ Return the horizontal kerning offset in pixels when rendering `char` after `previous_char`. """ kerning = self.face.get_kerning(previous_char, char) # The kerning offset is given in FreeType's 26.6 fixed point format, # which means that the pixel values are multiples of 64. return kerning.x / 64
We then use the resulting kerning offset to adjust the glyph’s drawing position. This reduces extraneous horizontal whitespace.
Let’s go back briefly to our kerning example with the letter pair AV. We saw there that the the glyph bitmaps for A and V overlapped slightly. In this case the glyph for V has a negative horizontal kerning offset and it is moved slightly left towards the A. To do this automatically we update Font.text_dimensions()
and Font.render_text()
to take the kerning offset into account:
class Font(object): def text_dimensions(self, text): width = 0 max_ascent = 0 max_descent = 0 previous_char = None for char in text: glyph = self.glyph_for_character(char) max_ascent = max(max_ascent, glyph.ascent) max_descent = max(max_descent, glyph.descent) kerning_x = self.kerning_offset(previous_char, char) # With kerning, the advance width may be less than the width of the # glyph's bitmap. Make sure we compute the total width so that # all of the glyph's pixels fit into the returned dimensions. width += max(glyph.advance_width + kerning_x, glyph.width + kerning_x) previous_char = char height = max_ascent + max_descent return (width, height, max_descent)
class Font(object): def render_text(self, text, width=None, height=None, baseline=None): if None in (width, height, baseline): width, height, baseline = self.text_dimensions(text) x = 0 previous_char = None outbuffer = Bitmap(width, height) for char in text: glyph = self.glyph_for_character(char) # Take kerning information into account before we render the # glyph to the output bitmap. x += self.kerning_offset(previous_char, char) # The vertical drawing position should place the glyph # on the baseline as intended. y = height - glyph.ascent - baseline outbuffer.bitblt(glyph.bitmap, x, y) x += glyph.advance_width previous_char = char return outbuffer
If we run the code at this stage we’ll see that it adjusts the glyph placement correctly – but produces unpleasant visual artefacts in some cases. If the glyph bounding boxes overlap, the glyph rendered last overwrites some of the previous glyph’s pixels.
To fix this visual artefact we update Bitmap.bitblt()
with a simple blending operation. We need this to draw text that contains glyphs with overlapping bounding boxes correctly. The updated method looks as follows:
class Bitmap(object): def bitblt(self, src, x, y): """Copy all pixels from `src` into this bitmap""" srcpixel = 0 dstpixel = y * self.width + x row_offset = self.width - src.width for sy in range(src.height): for sx in range(src.width): # Perform an OR operation on the destination pixel and the source pixel # because glyph bitmaps may overlap if character kerning is applied, # e.g. in the string "AVA", the "A" and "V" glyphs must be rendered # with overlapping bounding boxes. self.pixels[dstpixel] = self.pixels[dstpixel] or src.pixels[srcpixel] srcpixel += 1 dstpixel += 1 dstpixel += row_offset
Once you’ve made the change you should see the visual artefacts from glyph overlapping disappear. Congratulations for implementing kerning support! This also concludes
Example code / Demo
To see how it all fits together you can access the full source code here as a GitHub Gist.
For the example program to run you need to install freetype-py
. Additionally, place a font file called helvetica.ttf
in the program’s working directory.
What next?
Here are a few ideas for making this code more useful and/or to have some fun with it. If this article was helpful to you or if you’ve got suggestions I’d love to hear from you.
- Add a glyph cache to optimize text rendering. Rendering the same characters repeatedly should not require unpacking the glyph’s bitmap each time.
- Add support for rendering multiline text. This should take the font’s linegap value into account. Check the FreeType documentation for more information.
- Add support for vertical text rendering.
- Define your own file format for (bitmap) fonts and make the code work without FreeType.
- Use this code to implement a homebrew version of BSD’s
banner
.
-
A character string doesn’t really contain glyphs. Instead it contains characters that each map to a glyph as determined by the font face. ↩
-
It is overkill but I couldn’t really stop before seeing it work. Currently, I’m also not using any fonts that have kerning information on my radio LCD. I learned quite a bit about typography, though… ↩