Skip to content

Add native PPF pixel font rendering to simulator#92

Open
keith-oak wants to merge 1 commit into
badger:mainfrom
keith-oak:simulator-ppf-fonts
Open

Add native PPF pixel font rendering to simulator#92
keith-oak wants to merge 1 commit into
badger:mainfrom
keith-oak:simulator-ppf-fonts

Conversation

@keith-oak

Copy link
Copy Markdown

Summary

The simulator currently falls back to the default pygame font when loading .ppf pixel fonts, producing text that looks nothing like the badge hardware. This adds a native PPF binary format parser and renderer.

Changes

  • Added _PPFFont class that reads the PPF binary format (big-endian header, glyph table, 1bpp bitmap data)
  • Modified PixelFont.load() to use _PPFFont for .ppf files before falling back to default
  • Added import struct for binary parsing

Result

Text rendering in the simulator now matches the badge hardware pixel-for-pixel, using the actual ark.ppf and absolute.ppf font files.

The simulator previously fell back to default pygame fonts for Pimoroni's .ppf pixel font format. This adds a native PPF binary parser and renderer that produces pixel-accurate text matching the badge hardware.

PPF format: big-endian header + glyph table + 1bpp bitmap data.
Copilot AI review requested due to automatic review settings March 31, 2026 10:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds native parsing/rendering of .ppf Pixel Perfect Fonts in the Pygame simulator so text output matches badge hardware rendering more closely.

Changes:

  • Introduced _PPFFont to parse PPF binary font data and render 1bpp glyph bitmaps into a pygame.Surface.
  • Updated PixelFont.load() to prefer _PPFFont for .ppf files, with fallback to default pygame fonts on failure.
  • Added struct import to support big-endian binary parsing.

Comment on lines +596 to +607
self._glyph_count = struct.unpack_from(">I", self._data, 6)[0]
self._bbox_w = struct.unpack_from(">H", self._data, 10)[0]
self._bbox_h = struct.unpack_from(">H", self._data, 12)[0]
self._row_bytes = (self._bbox_w + 7) // 8
self._glyph_data_size = self._row_bytes * self._bbox_h
self._table_start = 46
self._bitmap_start = self._table_start + self._glyph_count * 6
self._glyphs = {}
for i in range(self._glyph_count):
off = self._table_start + i * 6
cp = struct.unpack_from(">I", self._data, off)[0]
w = struct.unpack_from(">H", self._data, off + 4)[0]

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_PPFFont.init parses fixed offsets (glyph_count/bbox/table_start) without validating the file signature or ensuring the computed table/bitmap ranges are within the file length. If a .ppf is truncated/corrupted, it may initialize successfully but later crash in render() with IndexError. Consider validating the magic (e.g., b"ppf!") and bounds-checking (len(_data) >= bitmap_start + glyph_count * glyph_data_size) and raising a clear exception so PixelFont.load() can safely fall back.

Suggested change
self._glyph_count = struct.unpack_from(">I", self._data, 6)[0]
self._bbox_w = struct.unpack_from(">H", self._data, 10)[0]
self._bbox_h = struct.unpack_from(">H", self._data, 12)[0]
self._row_bytes = (self._bbox_w + 7) // 8
self._glyph_data_size = self._row_bytes * self._bbox_h
self._table_start = 46
self._bitmap_start = self._table_start + self._glyph_count * 6
self._glyphs = {}
for i in range(self._glyph_count):
off = self._table_start + i * 6
cp = struct.unpack_from(">I", self._data, off)[0]
w = struct.unpack_from(">H", self._data, off + 4)[0]
data = self._data
data_len = len(data)
# Basic sanity checks: header size and magic signature.
# The PPF format used here is expected to start with b"ppf!".
if data_len < 16:
raise ValueError("PPF font file too short: %r" % (path,))
if data[:4] != b"ppf!":
raise ValueError("Invalid PPF font signature in %r" % (path,))
# Parse header values from fixed offsets.
self._glyph_count = struct.unpack_from(">I", data, 6)[0]
self._bbox_w = struct.unpack_from(">H", data, 10)[0]
self._bbox_h = struct.unpack_from(">H", data, 12)[0]
# Sanity-check bounding box dimensions.
if self._bbox_w <= 0 or self._bbox_h <= 0:
raise ValueError("Invalid PPF font metrics in %r" % (path,))
self._row_bytes = (self._bbox_w + 7) // 8
self._glyph_data_size = self._row_bytes * self._bbox_h
self._table_start = 46
self._bitmap_start = self._table_start + self._glyph_count * 6
# Ensure glyph table and bitmap data both fit within the file.
glyph_table_end = self._table_start + self._glyph_count * 6
if glyph_table_end > data_len:
raise ValueError("Corrupt PPF font (truncated glyph table): %r" % (path,))
bitmap_end = self._bitmap_start + self._glyph_count * self._glyph_data_size
if bitmap_end > data_len:
raise ValueError("Corrupt PPF font (truncated bitmap data): %r" % (path,))
self._glyphs = {}
for i in range(self._glyph_count):
off = self._table_start + i * 6
cp = struct.unpack_from(">I", data, off)[0]
w = struct.unpack_from(">H", data, off + 4)[0]

Copilot uses AI. Check for mistakes.
Comment on lines +615 to +623
w = 0
space_w = self._bbox_w // 3
for ch in text:
cp = ord(ch)
if cp == 32:
w += space_w + 1
elif cp in self._glyphs:
w += self._glyphs[cp][1] + 1
return (max(0, w - 1), self._bbox_h)

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space width is hard-coded as bbox_w // 3, even though PPF fonts commonly include an explicit glyph-table entry for U+0020 (space). This can make spacing differ from the actual font metrics. Prefer using the glyph-table width for codepoint 32 when present (and only fall back to bbox_w // 3 when it is not).

Copilot uses AI. Check for mistakes.
Comment on lines +617 to +637
for ch in text:
cp = ord(ch)
if cp == 32:
w += space_w + 1
elif cp in self._glyphs:
w += self._glyphs[cp][1] + 1
return (max(0, w - 1), self._bbox_h)

def render(self, text, antialias, color, *args, **kwargs):
text = str(text)
tw, th = self.size(text)
surf = pygame.Surface((max(1, tw), th), pygame.SRCALPHA)
space_w = self._bbox_w // 3
x = 0
for ch in text:
cp = ord(ch)
if cp == 32:
x += space_w + 1
continue
if cp not in self._glyphs:
continue

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For characters not present in the glyph table, both size() and render() currently skip without advancing x/w. That causes subsequent glyphs to overlap and size() to under-report width for strings containing unknown codepoints. Consider advancing by a fallback width (e.g., space width or bbox_w) and/or rendering a replacement glyph (like '?') to preserve layout.

Copilot uses AI. Check for mistakes.
Comment on lines +625 to +645
def render(self, text, antialias, color, *args, **kwargs):
text = str(text)
tw, th = self.size(text)
surf = pygame.Surface((max(1, tw), th), pygame.SRCALPHA)
space_w = self._bbox_w // 3
x = 0
for ch in text:
cp = ord(ch)
if cp == 32:
x += space_w + 1
continue
if cp not in self._glyphs:
continue
idx, gw = self._glyphs[cp]
bmp_off = self._bitmap_start + idx * self._glyph_data_size
for row in range(self._bbox_h):
for col in range(gw):
byte_idx = bmp_off + row * self._row_bytes + (col >> 3)
if self._data[byte_idx] & (1 << (7 - (col & 7))):
surf.set_at((x + col, row), color)
x += gw + 1

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_PPFFont.render() uses nested loops with surf.set_at() per pixel; in pygame this is very slow and can become a bottleneck when drawing lots of text each frame. At minimum, lock the surface during pixel writes (surf.lock()/unlock()), or switch to a faster bulk approach (PixelArray/surfarray) to keep simulator FPS stable.

Copilot uses AI. Check for mistakes.
def render(self, text, antialias, color, *args, **kwargs):
text = str(text)
tw, th = self.size(text)
surf = pygame.Surface((max(1, tw), th), pygame.SRCALPHA)

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render() guards against zero-width surfaces via max(1, tw) but not zero height; if bbox_h is ever 0 (bad/corrupt font) pygame.Surface((..., 0)) will raise. Consider using max(1, th) as well (and/or validating bbox_h > 0 during init).

Suggested change
surf = pygame.Surface((max(1, tw), th), pygame.SRCALPHA)
surf = pygame.Surface((max(1, tw), max(1, th)), pygame.SRCALPHA)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants