Add native PPF pixel font rendering to simulator#92
Conversation
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.
There was a problem hiding this comment.
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
_PPFFontto parse PPF binary font data and render 1bpp glyph bitmaps into apygame.Surface. - Updated
PixelFont.load()to prefer_PPFFontfor.ppffiles, with fallback to default pygame fonts on failure. - Added
structimport to support big-endian binary parsing.
| 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] |
There was a problem hiding this comment.
_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.
| 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] |
| 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) |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
_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.
| 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) |
There was a problem hiding this comment.
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).
| surf = pygame.Surface((max(1, tw), th), pygame.SRCALPHA) | |
| surf = pygame.Surface((max(1, tw), max(1, th)), pygame.SRCALPHA) |
Summary
The simulator currently falls back to the default pygame font when loading
.ppfpixel fonts, producing text that looks nothing like the badge hardware. This adds a native PPF binary format parser and renderer.Changes
_PPFFontclass that reads the PPF binary format (big-endian header, glyph table, 1bpp bitmap data)PixelFont.load()to use_PPFFontfor.ppffiles before falling back to defaultimport structfor binary parsingResult
Text rendering in the simulator now matches the badge hardware pixel-for-pixel, using the actual
ark.ppfandabsolute.ppffont files.