Last active
October 22, 2025 15:34
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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