Anti-Aliased MicroPython Fonts with a Few Kilobytes of Memory
If you've been following the other posts on this project, the previous entry covered how I replaced PicoGraphics with a custom framebuffer stack that drives SPI displays using vanilla MicroPython. With the display layer working, I needed a way to actually draw text.

The built-in framebuf module includes a minimal 8x8 pixel font, which is functional but too coarse for a real information display. I wanted proportional, anti-aliased text rendered from proper font files, and that meant implementing a BMFont loader (bmfont.py).
The General Idea
The primary constraint when rendering text on a microcontroller like the Raspberry Pi Pico is memory. I originally tried just loading the entire atlas into a buffer, but a typical anti-aliased font atlas easily exceeds the available RAM.
To work around this, my approach relies on processing the font files ahead of time so they can be read efficiently from the flash filesystem at runtime. At load time, the application parses the font descriptor to build a compact index of glyph metrics.
During rendering, it seeks directly to the required pixel rows within the binary atlas file, loading only the necessary bytes into a small scratch buffer before blitting them to the display. This keeps memory usage low and constant regardless of the font size.
The BMFont Format
BMFont is a bitmap font format created by Andreas Jönsson. A font is exported as two parts: a plain-text .fnt descriptor file and one or more PNG atlas images. The descriptor lists every glyph's position and metrics within the atlas; the atlas stores the actual pixel data. Most BMFont exporters can produce greyscale atlases where each byte represents a single pixel's opacity, which is exactly the format I needed.
The atlas images need a small amount of pre-processing before they can be used on the Pico. I wrote a simple conversion tool that strips the PNG header and stores the pixel data as a raw two-byte header followed by the greyscale bytes, one per pixel, row by row. The two-byte header encodes the atlas width so the reader can calculate row strides without any additional metadata. This format can be read directly by the Pico with simple seek and readinto calls, keeping the runtime code as straightforward as possible.
Parsing the Descriptor
The BMFont class parses the .fnt file at load time. The descriptor is a plain key-value text format, so parsing it is a matter of splitting lines and extracting token pairs. Rather than storing each glyph as a Python dict, I pack every glyph's metrics directly into a continuous bytearray using the struct module, mapping each Unicode character to its byte offset:
_GLYPH_FMT = '<HHHHhhhB'
if kind == "char":
off = len(font._glyph_data)
font._glyph_data.extend(struct.pack(_GLYPH_FMT,
x, y, width, height, xoffset, yoffset, xadvance, page))
font.chars[char_id] = off
The format string stores 15 bytes per glyph:
| Field(s) | Data Type | Struct Format | Size |
|---|---|---|---|
x, y, width, height |
Unsigned 16-bit integer | H |
2 bytes each |
xoffset, yoffset, xadvance |
Signed 16-bit integer | h |
2 bytes each |
page |
Unsigned byte | B |
1 byte |
On the Pico, storing 95 printable ASCII characters this way costs about 1.4 KB, which is significantly less than a list of dicts would require. This is also faster to unpack during rendering because struct.unpack_from operates directly on a bytearray without allocating intermediate objects.
Tinting and Palettes
The atlas pixels are raw opacity values: 0 is fully transparent, 255 is fully opaque. To render coloured text, those opacity values need to be mapped to actual display colours. The approach used here builds a 256-entry palette at initialisation time and hands it to MicroPython's framebuf.blit function, which applies the mapping during the copy. I wrote the _build_palette_bytes function to construct this buffer, and it handles two specific colour depths:
| Target Depth | Buffer Size | Bit Packing (R, G, B) | framebuf Format |
|---|---|---|---|
| 16-bit (RGB565) | 512 bytes | 5 bits, 6 bits, 5 bits | framebuf.RGB565 |
| 8-bit (RGB332) | 256 bytes | 3 bits, 3 bits, 2 bits | framebuf.GS8 |
16-bit (RGB565)
For an RGB565 target (2 bytes per pixel), it fills a 512-byte buffer. For a given tint colour, it scales the R, G, and B components proportionally from 0 up to their tinted maximum over the 256 intensity levels. It packs those scaled channels into a 16-bit integer (5 bits Red, 6 bits Green, 5 bits Blue), then splits that into two little-endian bytes. When drawing, framebuf.blit is passed framebuf.RGB565 as the palette format, telling it to read the palette as 16-bit colours.
8-bit (RGB332)
For an 8-bit target (1 byte per pixel), it only needs a 256-byte buffer. It scales the components the same way, but packs them into an 8-bit integer (3 bits Red, 3 bits Green, 2 bits Blue).
When framebuf.blit is called for an 8-bit display, it passes framebuf.GS8 as the palette format. GS8 (Grayscale 8-bit) is a 1-byte format. Because the destination buffer expects 1-byte pixels and the palette claims to supply GS8 (also 1-byte pixels), the underlying C code just copies the raw 8-bit values byte-for-byte from the palette into the destination buffer without trying to alter them. The result is perfectly tinted RGB332 pixels!
Palettes are cached in a module-level dict keyed on (tint_color, bytes_per_pixel), so rendering several strings in the same colour does not rebuild the palette on every call. The cache is capped at 16 entries to prevent unbounded memory growth.
Pulling Pixels from Flash
The atlas files live on the Pico's flash filesystem and are opened once, then passed to draw_text as file objects. Rather than loading an entire atlas into RAM (which at 512x512 pixels would cost 256 KB), the renderer reads only the rows it needs for each glyph directly from the file.
To handle this, I wrote a blit_region function. For a glyph at position (sx, sy) with dimensions (sw, sh) in the atlas, it reads each row by seeking to the correct byte offset and calling fh.readinto on a preallocated scratch buffer. Using a memoryview ensures no intermediate objects are created during the read pass:
src_offset = 4 + src_y * src_row_bytes + src_x
fh.seek(src_offset)
view = memoryview(scratch)[start_idx : start_idx + copy_width]
fh.readinto(view)
The 4 accounts for the two-byte header at the start of the file and two reserved bytes, and src_row_bytes is the atlas width (one byte per pixel). Using memoryview on the scratch buffer lets the file read directly into a slice without creating a temporary object.
Once a batch of rows is loaded into the scratch buffer, a FrameBuffer is overlaid on that region. The framebuf.blit routine then writes the anti-aliased pixels into the destination framebuffer using the colour palette:
source_framebuffer = (batch_view, copy_width, batch_h, framebuf.GS8)
framebuffer.blit(source_framebuffer, fb_x, dy + current_row, 0, (palette, 256, 1, palette_format))
The transparency key argument is 0, which causes pixels with an opacity value of 0 (fully transparent) to be skipped entirely. This is what allows text to render cleanly over any background without a solid bounding box.
Here is what the rendering process looks like slowed down, with bmfont.py pulling and blitting "1234567890" one horizontal row of pixels at a time:

Measuring Text
Measuring a string before rendering it is useful for centring or right-aligning text. I added a measure_text function that walks the same glyph sequence as draw_text, accumulating tight bounds across all glyphs by tracking the minimum and maximum extents in both axes. The logic calculates these pixel boundaries for each processed character:
glyph_left = cx + xoffset
glyph_right = glyph_left + width
glyph_top = cy + yoffset
glyph_bottom = glyph_top + height
It returns (width, height, min_x, min_y), where min_x and min_y are the offsets from the logical cursor origin to the top-left of the tight bounding box. These offsets matter because glyphs with negative xoffset or yoffset extend to the left of or above the cursor position, meaning the true tight width is not simply the sum of the advance widths.
Clipping the Output
Both draw_text and blit_region accept an optional clip tuple of (x, y, width, height). The clipping rectangle limits rendering to a sub-region of the framebuffer, which makes it straightforward to constrain text to a labelled panel or bounded widget without pre-measuring every string. blit_region calculates the intersection of the glyph bounds with the clip region before any file reads, so rows that fall entirely outside the clip are never loaded.
Drawbacks
While this approach is extremely memory-efficient, it does have a few drawbacks. Because the renderer strictly blits pre-rendered pixels exactly as they appear in the atlas, you cannot dynamically resize or scale the font at runtime. If you need to display text at different sizes, you are required to generate and load a separate BMFont atlas for each specific size, which consumes additional flash storage space.
Tying it together in a Textbox
The raw bmfont.py functions handle the low-level rendering, but building a user interface requires concepts like alignment and text wrapping. I built a wrapper module (textbox.py) to map these high-level requirements down to the raw font renderer.
The draw_textbox function takes standard layout arguments: horizontal and vertical alignment, wrapping toggles, and boundary dimensions and calculates the necessary offsets and clip rectangles before firing off the individual draw_text calls. Because rendering a lot of text can take a noticeable number of milliseconds and block the MicroPython event loop, the textbox routines are made asynchronous. Yielding with await asyncio.sleep(0) between processing lines keeps the rest of the application responsive during heavy redraws.
🏷️ byte atlas text bits file buffer format pixel font rendering glyph pixels palette row 8-bit
Please click here to load comments.