Skip to content

Instantly share code, notes, and snippets.

@njreid
Last active October 22, 2025 15:34
Show Gist options
  • Select an option

  • Save njreid/b4facdc648f07b3a3ecd57144af666e2 to your computer and use it in GitHub Desktop.

Select an option

Save njreid/b4facdc648f07b3a3ecd57144af666e2 to your computer and use it in GitHub Desktop.
This python script will convert a BDF bitmap font into the C header format expected by the Pico Graphics library from Pimoroni
#!/usr/bin/env python3
"""
BDF to Pimoroni bitmap font converter
Converts BDF bitmap fonts directly to Pimoroni bitmap font format.
BDF (Bitmap Distribution Format) is already a bitmap format, so this
doesn't require a TTF/WOFF render-to-bitmap step.
Usage:
python3 bdf2pimoroni.py -i input.bdf -o output.hpp -v variable_name
"""
import argparse
import sys
import re
import subprocess
from PIL import Image, ImageDraw
class BDFParser:
def __init__(self, filename):
self.filename = filename
self.font_name = ""
self.font_size = 8
self.font_ascent = 0
self.font_descent = 0
self.characters = {}
def parse(self):
"""Parse BDF file and extract character bitmaps"""
with open(self.filename, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith("FONT "):
self.font_name = line[5:].strip()
elif line.startswith("SIZE "):
parts = line.split()
if len(parts) >= 2:
self.font_size = int(parts[1])
elif line.startswith("FONT_ASCENT "):
self.font_ascent = int(line.split()[1])
elif line.startswith("FONT_DESCENT "):
self.font_descent = int(line.split()[1])
elif line.startswith("STARTCHAR "):
# Parse character
char_info = self._parse_character(lines, i)
if char_info:
char_code, char_data = char_info
self.characters[char_code] = char_data
i += char_data["lines_consumed"]
continue
i += 1
def _parse_character(self, lines, start_idx):
"""Parse a single character from BDF file"""
char_name = lines[start_idx].strip().split()[1]
char_data = {
"name": char_name,
"encoding": None,
"width": 0,
"height": 0,
"xoffset": 0,
"yoffset": 0,
"advance": 0,
"bitmap": [],
"lines_consumed": 0,
}
i = start_idx + 1
while i < len(lines):
line = lines[i].strip()
char_data["lines_consumed"] += 1
if line.startswith("ENCODING "):
char_data["encoding"] = int(line.split()[1])
elif line.startswith("DWIDTH "):
char_data["advance"] = int(line.split()[1])
elif line.startswith("BBX "):
parts = line.split()
char_data["width"] = int(parts[1])
char_data["height"] = int(parts[2])
char_data["xoffset"] = int(parts[3])
char_data["yoffset"] = int(parts[4])
elif line.startswith("BITMAP"):
# Read bitmap data
i += 1
char_data["lines_consumed"] += 1
while i < len(lines) and not lines[i].strip().startswith("ENDCHAR"):
hex_data = lines[i].strip()
if hex_data:
char_data["bitmap"].append(hex_data)
i += 1
char_data["lines_consumed"] += 1
break
elif line.startswith("ENDCHAR"):
break
i += 1
if char_data["encoding"] is not None:
return char_data["encoding"], char_data
return None
def hex_to_column_bytes(hex_lines, width, height, char_info, font_ascent):
"""Convert BDF hex bitmap to Pimoroni column-wise format"""
if not hex_lines or width == 0:
# Return appropriate number of bytes based on font height
if height > 8:
return [0, 0] * max(width, 1)
else:
return [0] * max(width, 1)
# Convert hex strings to binary matrix (row-wise from BDF)
bitmap = []
for hex_line in hex_lines:
if not hex_line:
continue
# Convert hex to integer
value = int(hex_line, 16)
# Convert to binary row - each bit represents a pixel
row_pixels = []
# Extract bits from left to right (MSB first)
for col in range(width):
bit_pos = 7 - col # Start from bit 7 for leftmost pixel
if bit_pos >= 0:
pixel = (value >> bit_pos) & 1
row_pixels.append(pixel)
else:
row_pixels.append(0)
bitmap.append(row_pixels)
# Handle fonts taller than 8 pixels (need 2 bytes per column)
two_bytes_per_column = height > 8
# Use proper BDF baseline positioning
# FONT_ASCENT defines the baseline position from the top of the font
# yoffset indicates where character sits relative to baseline
# Characters need to be positioned so their bottom edge aligns with baseline + yoffset
char_yoffset = char_info.get('yoffset', 0)
char_height = len(bitmap)
# Calculate where character should start so its bottom aligns with baseline + yoffset
# For yoffset=0: character bottom should be at baseline (row 7)
# For yoffset=-1: character bottom should be at row 8 (extends below baseline)
char_bottom_row = font_ascent - char_yoffset # Note: subtract because negative yoffset means below baseline
char_start_row = char_bottom_row - char_height + 1
column_bytes = []
for col in range(width):
if two_bytes_per_column:
# Generate 2 bytes per column for tall fonts
# Looking at font14_outline_data.hpp, the byte order is BOTTOM HALF FIRST:
# Quote mark: 0x00, 0x7c - first byte=0x00 (top 8 pixels), second byte=0x7c (bottom 8 pixels)
byte1 = 0 # First byte (bottom 8 pixels, bits 8-15 after shifting)
byte2 = 0 # Second byte (top 8 pixels, bits 16-23 after shifting)
# Place character pixels with baseline alignment
for bdf_row in range(len(bitmap)):
if col < len(bitmap[bdf_row]) and bitmap[bdf_row][col]:
# Map BDF row to font row with baseline alignment
font_row = char_start_row + bdf_row
# Ensure we're within bounds
if 0 <= font_row < height:
if font_row < 8:
# Top 8 pixels go in second byte
bit_pos = font_row
byte2 |= 1 << bit_pos
else:
# Bottom pixels go in first byte
bit_pos = font_row - 8
byte1 |= 1 << bit_pos
column_bytes.append(byte1) # First byte (bottom 8 pixels)
column_bytes.append(byte2) # Second byte (top 8 pixels)
else:
# Single byte per column for fonts <= 8 pixels
byte_val = 0
for bdf_row in range(len(bitmap)):
if col < len(bitmap[bdf_row]) and bitmap[bdf_row][col]:
# NOT inverted: BDF row 0 -> bit 0, row 7 -> bit 7
bit_pos = bdf_row
if bit_pos < 8:
byte_val |= 1 << bit_pos
column_bytes.append(byte_val)
return column_bytes
def generate_font_data(font_size, characters):
"""Generate font bitmap data and metadata"""
# Calculate max width and prepare data
max_width = 0
widths = []
all_char_data = []
# Define the exact character order that Pimoroni expects
# 96 standard ASCII characters (32-127) + 9 extras (set to blanks for now)
ascii_chars = list(range(32, 128)) # ASCII 32-127 (96 chars)
extra_chars = [32, 32, 32, 32, 32, 32, 32, 32, 32] # 9 blank spaces for now
all_chars = ascii_chars + extra_chars
# First pass: determine max width from available characters
for char_code in all_chars:
if char_code in characters:
char = characters[char_code]
actual_width = max(char["width"], 1)
max_width = max(max_width, actual_width)
# Calculate bytes per character based on font height
two_bytes_per_column = font_size > 8
bytes_per_char = max_width * (2 if two_bytes_per_column else 1)
# Second pass: generate bitmap data for all 105 characters in exact order
for char_code in all_chars:
if char_code in characters:
char = characters[char_code]
actual_width = max(char["width"], 1)
column_bytes = hex_to_column_bytes(
char["bitmap"], char["width"], font_size, char, 7
)
else:
# Missing character - use blank space
actual_width = 1
if two_bytes_per_column:
column_bytes = [0, 0] # 2 bytes per column for tall fonts
else:
column_bytes = [0] # 1 byte per column for short fonts
widths.append(actual_width)
# Pad character data to bytes_per_char (Pimoroni format requirement)
char_padded = column_bytes + [0] * (bytes_per_char - len(column_bytes))
all_char_data.extend(char_padded)
return max_width, widths, all_char_data
def write_pimoroni_header(
output_file, font_name, font_size, max_width, widths, all_char_data, var_name
):
"""Write Pimoroni bitmap font header file"""
# Write header file
with open(output_file, "w") as f:
f.write("#pragma once\n\n")
f.write('#include "bitmap_fonts.hpp"\n\n')
f.write(f"// Generated from BDF font: {font_name}\n")
f.write(f"// Font Size: {font_size}px\n")
f.write(f"// Generated with BDF2Pimoroni converter\n\n")
f.write(f"const bitmap::font_t {var_name} {{\n")
f.write(f" .height = {font_size},\n")
f.write(f" .max_width = {max_width},\n")
f.write(" .widths = {\n")
# Write widths array
for i in range(0, len(widths), 16):
chunk = widths[i : i + 16]
f.write(" " + ", ".join(map(str, chunk)) + ",\n")
f.write(" },\n")
f.write(" .data = {\n")
# Calculate bytes per character
two_bytes_per_column = font_size > 8
bytes_per_char = max_width * (2 if two_bytes_per_column else 1)
# Define the character mapping (same as in generate_font_data)
ascii_chars = list(range(32, 128)) # ASCII 32-127 (96 chars)
extra_chars = [32, 32, 32, 32, 32, 32, 32, 32, 32] # 9 blank spaces for now
all_chars = ascii_chars + extra_chars
# Character names for the extras
extra_names = ["Æ", "Þ", "ß", "æ", "þ", "£", "¥", "©", "°"]
# Write bitmap data in Pimoroni style - one character per line
for i in range(0, len(all_char_data), bytes_per_char):
char_data = all_char_data[i : i + bytes_per_char]
char_idx = i // bytes_per_char
char_code = all_chars[char_idx]
# Format hex data
hex_values = [f"0x{b:02x}" for b in char_data]
hex_line = ",".join(hex_values)
# Add character comment
if char_idx < 96: # ASCII characters
if char_code == 32:
char_display = " " # space
elif char_code == 92:
char_display = '\\ ""' # backslash (properly escaped)
else:
char_display = chr(char_code)
else: # Extra characters
extra_idx = char_idx - 96
char_display = extra_names[extra_idx]
f.write(f" {hex_line}, // {char_display}\n")
f.write(" }\n")
f.write("};\n")
def generate_test_bitmap(
output_file, font_size, max_width, characters, widths, all_char_data
):
"""Generate a test bitmap image showing all converted characters"""
# Calculate image dimensions
char_count = 95 # ASCII 32-126
chars_per_row = 16
rows = (char_count + chars_per_row - 1) // chars_per_row
# Each character cell: max_width * font_size pixels, scaled 4:1 with 1 pixel gaps
cell_width = max_width * 5 # 4 pixels + 1 gap per data pixel
cell_height = font_size * 5 # 4 pixels + 1 gap per data pixel
img_width = chars_per_row * cell_width
img_height = rows * cell_height
# Create image with white background
img = Image.new("RGB", (img_width, img_height), "white")
draw = ImageDraw.Draw(img)
# Draw each character
for char_idx in range(char_count):
ascii_code = char_idx + 32
char_width = widths[char_idx]
# Calculate data format
two_bytes_per_column = font_size > 8
bytes_per_column = 2 if two_bytes_per_column else 1
# Get character data
bytes_per_char = max_width * (2 if two_bytes_per_column else 1)
char_data_start = char_idx * bytes_per_char
char_data = all_char_data[char_data_start : char_data_start + bytes_per_char]
# Calculate position in grid
row = char_idx // chars_per_row
col = char_idx % chars_per_row
start_x = col * cell_width
start_y = row * cell_height
# Draw character bitmap
for data_col in range(char_width):
col_start_idx = data_col * bytes_per_column
if col_start_idx < len(char_data):
if two_bytes_per_column and col_start_idx + 1 < len(char_data):
# Combine two bytes: upper byte (bits 15-8) and lower byte (bits 7-0)
byte1 = char_data[col_start_idx] # Upper 8 pixels
byte2 = char_data[col_start_idx + 1] # Lower 8 pixels
combined_data = (byte1 << 8) | byte2 # 16-bit value
# Draw pixels from combined 16-bit value
for bit_pos in range(min(font_size, 16)):
if (combined_data >> (15 - bit_pos)) & 1:
pixel_x = start_x + data_col * 5
pixel_y = start_y + bit_pos * 5
# Draw 4x4 black square
for px in range(4):
for py in range(4):
draw.point((pixel_x + px, pixel_y + py), "black")
else:
# Single byte per column
byte_val = char_data[col_start_idx]
for bit_pos in range(min(font_size, 8)):
if (byte_val >> (7 - bit_pos)) & 1:
pixel_x = start_x + data_col * 5
pixel_y = start_y + bit_pos * 5
# Draw 4x4 black square
for px in range(4):
for py in range(4):
draw.point((pixel_x + px, pixel_y + py), "black")
# Draw character label (ASCII code)
if 32 <= ascii_code <= 126:
label_x = start_x + 2
label_y = start_y + font_size * 5 - 10
draw.text((label_x, label_y), str(ascii_code), fill="gray")
# Save as PNG first
png_file = output_file.replace(".hpp", "_test.png")
img.save(png_file, "PNG")
# Convert to GIF using ImageMagick if available
gif_file = output_file.replace(".hpp", "_test.gif")
try:
# Try modern ImageMagick command first
try:
subprocess.run(["magick", png_file, gif_file], check=True)
except FileNotFoundError:
# Fall back to old convert command
subprocess.run(["convert", png_file, gif_file], check=True)
print(f"Generated test bitmap: {gif_file}")
# Remove PNG if GIF was created successfully
import os
os.remove(png_file)
except (subprocess.CalledProcessError, FileNotFoundError):
# ImageMagick not available or failed, keep PNG
print(f"Generated test bitmap: {png_file}")
print("Note: ImageMagick not available - saved as PNG instead of GIF")
def main():
parser = argparse.ArgumentParser(
description="Convert BDF font to Pimoroni bitmap format"
)
parser.add_argument("-i", "--input", required=True, help="Input BDF file")
parser.add_argument("-o", "--output", required=True, help="Output .hpp file")
parser.add_argument(
"-v", "--var_name", required=True, help="Variable name for the font"
)
parser.add_argument(
"-p",
"--print_chars",
action="store_true",
help="Print character info for debugging",
)
parser.add_argument(
"-t",
"--test_bitmap",
action="store_true",
help="Generate test bitmap image showing all characters",
)
args = parser.parse_args()
print(f"Converting {args.input} to {args.output}")
# Parse BDF file
parser = BDFParser(args.input)
parser.parse()
print(f"Font: {parser.font_name}")
print(f"Size: {parser.font_size}px")
print(f"Characters found: {len(parser.characters)}")
if args.print_chars:
# Print some character info for debugging
for code in [33, 65, 97, 114, 105, 106]: # !, A, a, r, i, j
if code in parser.characters:
char = parser.characters[code]
print(f"\nChar {chr(code)} ({code}):")
print(f" Size: {char['width']}x{char['height']}")
print(f" Offsets: x={char['xoffset']}, y={char['yoffset']}")
print(f" Bitmap: {char['bitmap']}")
# Show visual representation
print(" Visual:")
for hex_line in char["bitmap"]:
if hex_line:
value = int(hex_line, 16)
visual = ""
for col in range(char["width"]):
bit_pos = 7 - col
if bit_pos >= 0 and (value >> bit_pos) & 1:
visual += "#"
else:
visual += "."
print(f" {visual}")
column_bytes = hex_to_column_bytes(
char["bitmap"], char["width"], 9, char, 7
)
print(f" Column bytes: {[hex(b) for b in column_bytes]}")
# Generate font data
max_width, widths, all_char_data = generate_font_data(
parser.font_size, parser.characters
)
# Write header file
write_pimoroni_header(
args.output,
parser.font_name,
parser.font_size,
max_width,
widths,
all_char_data,
args.var_name,
)
# Generate test bitmap if requested
if args.test_bitmap:
generate_test_bitmap(
args.output,
parser.font_size,
max_width,
parser.characters,
widths,
all_char_data,
)
print(f"Generated {args.output}")
return 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment