M0AGX / LB9MG

Amateur radio and embedded systems

Making graphics and fonts for embedded systems

Microcontroller systems with graphical displays require a way to display text. In case of alphanumeric displays (like HD44780) it is easy - just send your ASCII bytes to the display. Graphical displays operate on individual pixels so firmware must generate the graphics and texts on the fly.

In this post I show the complete multi-step process from a TrueType (.ttf) font file to autogenerated C code that can be used by a graphics library to display texts on a microcontroller. All code (including the embedded graphics library) is available on Github.

Make sure to read also my post about a practical application on an ePaper display.

digits bitmap

Because of performance limitations the graphics and fonts are stored as bitmaps. It is simply easy for the microcontroller to copy bytes around, rather than running a rendring engine (though simple vector graphics are also very efficient).

The end goal is to have a regular C arrays of uint8_t bytes with each bit holding an individual pixel that can later be transferred by the firmware in appropriate order to the display. Packing pixels into bits is the most efficient way of storage if compression is not used.

Overview

  1. The starting point for fonts is a .ttf file (the "regular" font format used by Linux/Windows/Mac and other OSes)
  2. The starting point for graphics is a .png file (black and white)
  3. Font file is converted into a bitmap containing all necessary glyphs ("characters") and JSON file that describes where each glyph starts and ends
  4. Python script reads the .png files and generates appropriate C arrays and reads the JSON file describing where within a bitmap a particular glyph lies
  5. Generated C code can be compiled alongside other project files

Finding font files

You have a lot of preinstalled fonts that come with your operating system, however they may not always look the best on small, low-resolution displays (and due to licensing they may not be free to use).

I pick fonts from the Font Library or Google Fonts. At this point - it is all about aesthetics. Some fonts will look good on your small display some will not. You have to choose between monospaced (each letter has the same width) and proportional fonts. I also advise against serifs - they usually not look good on small displays.

Turning fonts into bitmaps

Download and build FontBuilder. This program automates the whole process. One note about building it in Gentoo - I had to build it by qtchooser -qt=5 -run-tool=qmake && make . Otherwise it would try to build with Qt4 and fail.

The easiest way to import new font files under Linux is to place them in ~/.fonts/ directory.

Step 1

The application has a set of tabs on the left and a set on the right. I prefer to have the "Full font preview" on the right. On the left you can: pick the font, set font size, tweak font width and height. It always distorts the font but changing the dimensions up to around 10% usually does not make the font look bad.

Things to look at: the image (of course) and dimensions - especially the height if the embedded graphics library has a limitation (eg. multiple of 8 pixels).

Let's start with a large font to display digits (example use: a thermometer). font builder screenshot(click to enlarge)

Step 2

Fonts have a lot of different glyphs (characters). Not all of them may be useful in your application (like special symbols or diacritics from languages that will never be supported by your device). Each glyph will consume valuable bytes of microcontroller flash memory so it makes sense to narrow down the character set using the "Characters" tab on the left.

In my case I limited this large font to digits and a minus sign. font builder screenshot(click to enlarge)

Step 3

In the "Layout" tab select "Line layout", disable "One pixel separator". You can also tweak the final bitmap size by adding padding.

To make the final dimensions right you may have to tweak:

  • font size ("Font" tab)
  • font DPI ("Font" tab
  • Scale % ("Font" tab)
  • Padding ("Layout" tab)

Tip: if you leave one pixel padding at the top and bottom of the bitmap you can write lines of text "next to each other" (ie. without extra work from the graphics code on the microcontroller) and the rows will have enough vertical spacing to be legible. Yes - it is wasted space but the graphics library can be simplified. For example if the library expects multiple-of-8 pixels heights it makes sense to have the font 14-pixels high and add padding pixel on top and the bottom, instead of having to add that padding to a 16-pixel high font later on the microcontroller.

Step 4

When the bitmap looks good and dimensions are right you can simply save the bitmap as a PNG file and its description in JSON format.
font builder screenshot

The file will be white-on-transparent, which may not be exactly what you want to use. I wanted to have simple black-on-white. The Python code generator can handle transparency but the font must be at least black. You can do it it any graphics editor or using ImageMagick in the terminal:mogrify -fill "#000000" -opaque "#ffffff" generalemono_a_65.png

this will substitute all white with black and leave transparency alone.

The end result looks like this:
font bitmap example

Another font examples

I did exactly the same steps to make a smaller fonts that have all popular glyphs:
font bitmap example
font bitmap example
font builder screenshot
font builder screenshot
font builder screenshot

That is how they look on a real ePaper display. Continue reading for the code generation instructions.
epaper display screenshot epaper display screenshot

C code

The PNG files have now to be converted into regular C arrays. I wrote a Python script that does that automatically. It takes the input and output directories as command line arguments. The output files are called resources.h and resources.c.

Let's begin with the representation of bitmaps in my simple library (it is available on Github). There is a single header that defines the types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#pragma once
#include <stdint.h>

/* This is a generic bitmap */
typedef struct {
    const uint16_t size_x; //size is in pixels
    const uint16_t size_y;
    const uint8_t * const payload;
} bitmap_t;

/* This is a font glyph description - for now it does not support kerning */
typedef struct {
    const char character; //ASCII code
    const uint8_t width;
    const uint16_t x_offset;
} glyph_t;

typedef struct {
    const bitmap_t * const bitmap;
    const uint8_t glyph_count;
    const glyph_t *glyphs; //pointer to array of glypt_t elements
} font_t;

Bitmap type holds the dimensions and pointer to the array of pixels. Glyph type holds the width and offset. Finally the font type combines them both. The code generator outputs the bitmap type, bitmap array, glyph array and finally the font struct.

Let's look at a generated header:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
//AUTOGENERATED FILE! DO NOT EDIT!
#include "graphic_types.h"
extern const bitmap_t BITMAP_roboto_condensed_regular_18;
extern const bitmap_t BITMAP_roboto_condensed_regular_14;
extern const bitmap_t BITMAP_generalemono_a_65;
extern const bitmap_t BITMAP_generalemono_a_17;

extern const font_t FONT_roboto_condensed_regular_14;

extern const font_t FONT_generalemono_a_17;

extern const font_t FONT_roboto_condensed_regular_18;

extern const font_t FONT_generalemono_a_65;

and the data itself:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
//AUTOGENERATED FILE! DO NOT EDIT!
#include "resources.h"

static const uint8_t generalemono_a_65_payload[] ={
0xFF,0xFF,0xFF,0xFF,0xFF,0x00,0x01,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0x00,0x01,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0x00,
0x01,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0x00,0x01,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0x00,0x01,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0x00,0x01,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0x00,0x01,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0x00,
0x01,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0x00,0x01,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0x00,0x01,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0x00,0x01,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0x00,0x01,0xFF,
---------------- CUT FOR CLARITY ----------------
0xFF,0x00,0x01,0xFF,0xFF,0xFC,0x00,0x03,0x80,0x00,
0xFF,0xFF,0xFF,0x00,0x01,0xFF,0xFF,0xFC,0x00,0x03,
0x80,0x00,0xFF,0xFF,0xFF,0x00,0x01,0xFF,0xFF,0xFC,
0x00,0x03,0x80,0x00,0xFF,0xFF,0xFF,0x00,0x01,0xFF,
0xFF,0xFC,0x00,0x03,0x80,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x03,0x80,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03,0x80,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03,
0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x03,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x03,0x80,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x03,0x80,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x07,0xC0,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x07,
0xC0,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x0F,0xE0,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x0F,0xF0,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x1F,0xF8,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x3F,0xFC,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,
0xFF,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x01,0xFF};

const bitmap_t BITMAP_generalemono_a_65 = {.size_x=96, .size_y=591, .payload=generalemono_a_65_payload};

static const glyph_t FONT_GLYPHS_generalemono_a_65[] = {
{ .character=45/*-*/, .width=20/*MANUALLY CORRECTED!*/, .x_offset=0 },
{ .character=48/*0*/, .width=55, .x_offset=41 },
{ .character=49/*1*/, .width=55, .x_offset=96 },
{ .character=50/*2*/, .width=55, .x_offset=151 },
{ .character=51/*3*/, .width=55, .x_offset=206 },
{ .character=52/*4*/, .width=55, .x_offset=261 },
{ .character=53/*5*/, .width=55, .x_offset=316 },
{ .character=54/*6*/, .width=55, .x_offset=371 },
{ .character=55/*7*/, .width=55, .x_offset=426 },
{ .character=56/*8*/, .width=55, .x_offset=481 },
{ .character=57/*9*/, .width=55, .x_offset=536 },
};

const font_t FONT_generalemono_a_65 = { .bitmap = &BITMAP_generalemono_a_65, .glyph_count = 11, .glyphs = FONT_GLYPHS_generalemono_a_65 };

The file (limited to a single font and cut for clarity) contains the pixel array, bitmap description and font description. I also made a dirty hack... I decreased the width of the hyphen to make it better fit the display (it was just faster to edit a single number, than cutting the bitmap and changing all offsets).

How the code is generated

The script looks for all .png and .json files in the input directory. There are just three functions - make_image_array,serialize_image_array,make_font_description. Two latter ones simply reformat the data and "print" it in a C-compatible way.

The first function is mildly interesting. The script uses Python Imaging Library (PIL) to read .png files at bitmaps. It reads the pixels from left to right, from top to bottom (like a CRT TV would do) and packs 8 pixels into a single byte, that is added into an array. This is a serious implication to the library running on the microcontroller, because the order has to stay the same to make any sense when displayed.

The display I am targeting (2.9" ePaper) has a "sideways" orientation, ie. has to be written in from top to bottom, from left to right. The easiest way to implement that is to rotate all images before serializing them into the arrays so there is a rotate variable in the script. In my case all images are rotated 90 degrees counterclockwise (or 270 degrees clockwise). This also simplifies the graphics library when writing proportional text, because it is read "sideways" so glyphs can easily have arbitrary widths.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#!/usr/bin/env python
import json
import os
from PIL import Image
import sys

#returns tuple (name, height, width, payload)
#Serialization order is left-to-right, top-to-bottom so for the eInk
#display all images have to be rotated 90 degrees counterclockwise
def make_image_array(file_path, rotate = False):
    im = Image.open(file_path)
    if rotate:
        im = im.rotate(rotate, expand=True)
    width, height = im.size
    print "Processing file %s color depth is %s, width %d, height %d" % (file_path, im.mode, width, height)
    rgb_im = im.convert('1') #make sure the color depth is 1-bit
    name = os.path.splitext(file_name)[0]

    payload = []
    for y in range(0,height):
        bit_list = [0] * 8
        bit_index = 0
        for x in range(0,width):
            pixel = rgb_im.getpixel((x,y))
            if pixel:
                bit_list[bit_index] = 1
            bit_index += 1
            if bit_index == 8:
                byte = 0
                for bit in bit_list:
                    byte = (byte << 1) | bit
                payload.append(byte)
                bit_index = 0
                bit_list = [0] * 8
        if bit_index % 8: #there is something left over in this row (row is not multiple of 8 bits)
            byte = 0
            for bit in bit_list:
                byte = (byte << 1) | bit
            payload.append(byte)
    return (name, height, width, payload)

def serialize_image_array(name, height, width, payload):
    print 'Serializing image array %s' % name
    header_output = 'extern const bitmap_t BITMAP_'+name+';\n'
    output = '\nstatic const uint8_t %s_payload[] ={\n' % name
    row = ''
    c = 0;
    for byte in payload:
        row += '0x%02X,' % byte
        c += 1
        if c > 9:
            c = 0
            row += '\n'
    row = row[0:-1] #get rid of last comma
    row += '};\n\n'
    output += row
    output += 'const bitmap_t BITMAP_%s = {.size_x=%d, .size_y=%d, .payload=%s_payload};\n' % (name, width, height, name)
    return (header_output, output)

#returns tuple (header, data) with header and .c code
def make_font_description(file_path):
    print 'Making font description from %s' % file_path
    input_description = json.loads(open(file_path).read())
    name = os.path.splitext(file_name)[0]
    output_header = '\nextern const font_t FONT_%s;\n' % name
    output_data = ''

    output_data += 'static const glyph_t FONT_GLYPHS_%s[] = {\n' % name
    counter = 0
    for g in input_description['symbols']:
        output_data += '{ .character=%d/*%c*/, .width=%d, .x_offset=%d },\n' % (g['id'], chr(g['id']), g['width'], g['x'])
        counter += 1
    output_data += '};\n\n'

    output_data += '\nconst font_t FONT_%s = { .bitmap = &BITMAP_%s, .glyph_count = %d, .glyphs = FONT_GLYPHS_%s };\n\n' % (name, name, counter, name)

    return (output_header, output_data)

#----------------- main -----------------
try:
    source_path = sys.argv[1]
    destination_path = sys.argv[2]
except Exception:
    print "Usage: ./make_resource.py path_to_images path_to_output_C_files\n"
    sys.exit(1)

output_h_file = open(destination_path+'/resources.h', 'w')
output_c_file = open(destination_path+'/resources.c', 'w')

output_h_file.write('//AUTOGENERATED FILE! DO NOT EDIT!\n')
output_h_file.write('#include "graphic_types.h"\n')

output_c_file.write('//AUTOGENERATED FILE! DO NOT EDIT!\n')
output_c_file.write('#include "resources.h"\n')

rotation = 270

#serialize images
for file_name in os.listdir(source_path):
    if file_name.endswith(".png") or file_name.endswith(".PNG"):
        file_path = source_path+file_name
        name, height, width, payload = make_image_array(file_path, rotation)
        header, data = serialize_image_array(name, height, width, payload)
        output_h_file.write(header)
        output_c_file.write(data)

#serialize font descriptions - font description must come after bitmaps to compile properly
for file_name in os.listdir(source_path):
    if file_name.endswith(".json"):
        file_path = source_path+file_name
        header, data = make_font_description(file_path)
        print header
        output_h_file.write(header)
        output_c_file.write(data)