Bitmap font format for embedded systems

December 12, 2025 ยท View on GitHub

Compact binary bitmap font format tailored for embedded targets with limited memory. Highlights:

  • Kerning support (missing in legacy BDF/PCF).
  • Configurable bits per pixel (1-4) for bitonal or anti-aliased glyphs.
  • Storage optimized for bounding boxes with built-in compression.

The format is based on the OpenType spec (https://docs.microsoft.com/en-us/typography/opentype/spec/) but simplified for bitmaps:

  • No separate global header; everything lives in the head table.
  • advanceWidth moves to the glyf table.
  • Glyph count limited to 65536 (code points still up to 0x10FFFF).
  • No vertical fonts or ligatures.

Unless noted otherwise, multi-byte numbers are little-endian.

Table: head (font header)

Initial Reference

Global values.

Size (bytes)Description
4Record size (for quick skip)
4head (table marker)
4Version (reserved)
2Number of additional tables (2-byte align helper)
2Font size (px), as defined in converter params
2Ascent (uint16), from Font.ascender of opentype.js (usually HHead ascent)
2Descent (int16, negative), from Font.descender of opentype.js (usually HHead descent)
2typoAscent (uint16), typographic ascent
2typoDescent (int16), typographic descent
2typoLineGap (uint16), typographic line gap
2min Y (quick check for line intersections)
2max Y
2Default advanceWidth (uint16) if advanceWidth bits length = 0
2kerningScale, FP12.4 unsigned; scales kerning data to 1 byte
1indexToLocFormat in loca (0 - Offset16, 1 - Offset32)
1glyphIdFormat (0 - 1 byte, 1 - 2 bytes)
1advanceWidthFormat (0 - uint, 1 - unsigned with 4 fractional bits)
1Bits per pixel (1, 2, 3, or 4)
1Glyph BBox x/y bits length (unsigned)
1Glyph BBox w/h bits length (unsigned)
1Glyph advanceWidth bits length (unsigned, may be FP4)
1Compression algorithm ID (0 - raw bits, 1 - RLE with XOR prefilter, 2 - RLE without prefilter)
1Subpixel rendering: 0 none, 1 horizontal x3, 2 vertical x3
1Reserved (align to 2x)
2Underline position (int16), scaled post.underlinePosition
2Underline thickness (uint16), scaled post.underlineThickness
xUnused (align header length to 4x)

Note: Ascent + abs(Descent) may not equal font size.

Table: cmap

Initial Reference

Maps code points to internal IDs via optimized subtables for compact storage.

Differs from the original by using fixed subtable headers for faster lookup. Only a subset of subtable formats is implemented.

Size (bytes)Description
4Record size (for quick skip)
4cmap (table marker)
4Subtables count (4 to simplify align)
16Subtable 1 header
16Subtable 2 header
......
?Subtable 1 data (aligned to 4)
?Subtable 2 data (aligned to 4)
......

All subtables are non-intersecting ranges. Headers and content are ordered by codePoint for fast scan or binary search.

Subtable header

Size (bytes)Description
4Data offset (or 0 if data segment does not exist)
4Range start (min codePoint)
2Range length (up to 65535)
2Glyph ID offset (for delta-coding)
2Data entries count (for sparse data)
1Format type (0 => format 0, 1 => format sparse, 2 => format 0 tiny, 3 => format sparse tiny)
1- (align to 4)

Subtable "format 0" data

Initial Reference

Array[uint8] (continuous, delta-coded), or empty data.

  • Index = codePoint - (Min codePoint)
  • Map to Glyph ID as Value + Glyph ID offset.
bytesdescription
1delta-encoded Glyph ID for (range_start + 0) codePoint
1delta-encoded Glyph ID for (range_start + 1) codePoint
......
1delta-encoded Glyph ID for (range_end) codePoint

"Missed" chars map to 0.

Subtable "format sparse" data

For non-continuous sets (CJK subsets, for example). Array[entries] of delta-coded code points + Array[entries] of delta-coded glyph IDs.

bytesdescription
2(codePoint1 - range_start)
2(codePoint2 - range_start)
......
2(last codepoint - range_start)
2delta-encoded Glyph1 ID
2delta-encoded Glyph2 ID
......
2delta-encoded last glyph ID

Subtable "format 0 tiny"

Special case of "format 0" without IDs index.

In most cases, glyph IDs are consecutive and have no gaps. Then we can calculate ID value as glyph ID offset + codepoint index.

This format has only a header and no data.

Subtable "format sparse tiny"

Exactly as "format sparse", but without glyph ID index at the end (code points only).

See "format 0 tiny" for details.

Table: loca

Initial Reference

Data offsets in glyf for each glyph id. Can be Offset16 or Offset32 (defined in head).

Size (bytes)Description
4Record size (for quick skip)
4loca (table marker)
4Entries count (4 to simplify align)
2 or 4id1 offset
2 or 4id2 offset
......

Table: glyf

Initial Reference

Contains glyph bitmap data. Data offsets for each id are defined in loca.

Coordinate system is (right, up) - point (0, 0) located at (baseline, left) corner, as in OpenType fonts. Glyph BBox (x, y) defines bottom-left corner.

Image inside the BBox is drawn from top-left corner, to the right and down.

Size (bytes)Description
4Record size (for quick skip)
4glyf (table marker)
??Glyph id1 data
??Glyph id2 data
......

Note: Glyph ID 0 is reserved for "undefined". It is recommended to set it to 0xFFFE char image.

Glyph data is NOT aligned and should be loaded byte-by-byte.

Glyph data

Stream of bits.

Bounding box is NOT tied to typographic area (height * advanceWidth) and can shift. The real image can overflow typographic space.

Size (bits)Description
NNadvanceWidth (length/format in font header, may have 4 fractional bits)
NNBBox X (length in font header)
NNBBox Y (length in font header)
NNBBox Width (length in font header)
NNBBox Height (length in font header)
??Compressed bitmap

If bitmaps are generated for subpixel rendering, then BBox Width or BBox Height value will be 3x more than the "normal" one. They always contain real size of content, not rendered size.

Table kern

Initial References: 1, 2

Kerning info is optional. Only a small subset of kern/GPOS features is supported:

  • Horizontal kerning only.
  • Two subtable formats for compact storage (sorted pairs and table of glyph classes).
  • No feature stacking (combination of subtables). The kern table contains data in one of the supported formats.

Stored kerning values are always FP4.4 fixed point with sign. Multiply by FP12.4 kerningScale from the font header. Seven-bit resolution covers common cases, but kerning for fonts over 40px can exceed signed FP4.4. kerningScale widens the usable range.

Data layout:

Size (bytes)Description
4Record size (for quick skip)
4kern (table marker)
1Format type (0 and 3 supported)
3- (align)
??Format content

Format 0 (sorted pairs)

Sorted list of (id_left, id_right, value) where ids are combined into uint32_t and binary searchable. Good for ASCII-size sets; inefficient for multi-language.

Unlike the original, id pairs and values are stored separately for alignment.

Content:

Size (bytes)Description
4Entries (pairs) count
2 or 4Kerning pair 1 (glyph id left, glyph id right)
2 or 4Kerning pair 2
......
2 or 4Kerning pair last
1Value 1
1Value 2
......
1Value last

Kerning pair size depends on glyphIdFormat from the header.

Format 3 (array M*N of classes)

See Apple's TrueType reference.

Data format is very similar to the Apple suggestion, except kerning values are stored directly (no index) because values are always 1 byte.

Content:

Size (bytes)Description
2The number of glyphs in this font (class mapping length). M.
1The number of left-hand classes (table rows width). W.
1The number of right-hand classes (table column height). H.
MLeft-hand classes mapping; index = glyph_id, value => class id.
MRight-hand classes mapping.
W*HKerning values array.

Class id 0 is reserved and means "kerning does not exist" for this glyph. It is NOT stored in the kerning array.

Resulting data index: kerningArray[(leftClass-1)*rightClassesCount + (rightClass-1)]

As noted in the original spec, this format is restricted to 256 classes. That is enough for complex cases. For a full Roboto Regular font dump, size of auto-restored table is about 80*100 (auto-restore means reverse-building from pairs). For this reason Format 2 support was not added to this spec.

Compression

Glyph data uses modified RLE compression - I3BN, with prefilter and tuned options.

Everything works with "pixels" (groups of 2, 3, 4, or 8 bits). That does not work for bitonal fonts, but those are small enough.

Notable compression gain starts around 30px sizes because:

  • Kerning table occupies fixed size, notable for small fonts.
  • Input is already compacted by storing bounding box data only.

Decompression cost is negligible, so compression stays enabled except for 1-bpp fonts.

Pre-filter

Before compression, lines are XOR-ed with previous ones. That gives about 10% extra gain. Since no advanced entropy encoding is used, XOR is sufficient and often better than diff:

  • XOR does not depend on pixel resolution/encoding.
  • Can be optimized with word-wide operations if needed.

Compression algorithm

  1. At least 1 repeat is required to switch into RLE mode (first pixel passes as is). That helps to pass anti-aliasing pixels without size increase.
  2. First 10 repeats are replaced with 1 bit (0 - end).
  3. Next (11th) is followed by 6-bit counter. If the counter overflows, max possible value is used and the process restarts from step 1.

See I3BN for the initial idea and images.

Constants (repeats and counter size) were chosen by experimentation on different font sizes and bpp. They do not affect results much (1-2%).

Also see:

Decompression

Everything is the reverse of compression. Data length is determined by bitmap size.

To improve performance, you may apply these hacks during decompression:

  1. Align lines in decompressed images to 1 or 4 bytes to simplify later steps (for example, post-filter line XOR by words).
  2. Return 3-bpp pixels as 4-bpp or 8-bpp to simplify composition.