perf: Reduce overall flash usage by 30.7% by compressing built-in fonts (#831)

## Summary

**What is the goal of this PR?**

Compress reader font bitmaps to reduce flash usage by 30.7%.

**What changes are included?**

- New `EpdFontGroup` struct and extended `EpdFontData` with
`groups`/`groupCount` fields
- `--compress` flag in `fontconvert.py`: groups glyphs (ASCII base group
+ groups of 8) and compresses each with raw DEFLATE
- `FontDecompressor` class with 4-slot LRU cache for on-demand
decompression during rendering
- `GfxRenderer` transparently routes bitmap access through
`getGlyphBitmap()` (compressed or direct flash)
- Uses `uzlib` for decompression with minimal heap overhead.
- 48 reader fonts (Bookerly, NotoSans 12-18pt, OpenDyslexic) regenerated
with compression; 5 UI fonts unchanged
- Round-trip verification script (`verify_compression.py`) runs as part
of font generation
## Additional Context

## Flash & RAM

| | baseline | font-compression | Difference |
|--|--------|-----------------|------------|
| Flash (ELF) | 6,302,476 B (96.2%) | 4,365,022 B (66.6%) | -1,937,454 B
(-30.7%) |
| firmware.bin | 6,468,192 B | 4,531,008 B | -1,937,184 B (-29.9%) |
| RAM | 101,700 B (31.0%) | 103,076 B (31.5%) | +1,376 B (+0.5%) |

## Script-Based Grouping (Cold Cache)

Comparison of uncompressed baseline vs script-based group compression
(4-slot LRU cache, cleared each page). Glyphs are grouped by Unicode
block (ASCII, Latin-1, Latin Extended-A, Combining Marks, Cyrillic,
General Punctuation, etc.) instead of sequential groups of 8.

### Render Time

| | Baseline | Compressed (cold cache) | Difference |
|---|---|---|---|
| **Median** | 414.9 ms | 431.6 ms | +16.7 ms (+4.0%) |
| **Pages** | 37 | 37 | |

### Memory Usage

| | Baseline | Compressed (cold cache) | Difference |
|---|---|---|---|
| **Heap free (median)** | 187.0 KB | 176.3 KB | -10.7 KB |
| **Heap free (min)** | 186.0 KB | 166.5 KB | -19.5 KB |
| **Largest block (median)** | 148.0 KB | 128.0 KB | -20.0 KB |
| **Largest block (min)** | 148.0 KB | 120.0 KB | -28.0 KB |

### Cache Effectiveness

| | Misses/page | Hit rate |
|---|---|---|
| **Compressed (cold cache)** | 2.1 | 99.85% |

------

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

  Did you use AI tools to help write this code? _**YES**_
Implementation was done by Claude Code (Opus 4.6) based on a plan
developed collaboratively. All generated font headers were verified with
an automated round-trip decompression test. The firmware was compiled
successfully but has not yet been tested on-device.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adrian Wilkins-Caruana
2026-02-19 20:30:15 +11:00
committed by GitHub
parent f16c0e52fd
commit 47aa0dda76
73 changed files with 126417 additions and 246010 deletions

View File

@@ -3,6 +3,18 @@
#include <Logging.h>
#include <Utf8.h>
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
if (fontData->groups != nullptr) {
if (!fontDecompressor) {
LOG_ERR("GFX", "Compressed font but no FontDecompressor set");
return nullptr;
}
uint16_t glyphIndex = static_cast<uint16_t>(glyph - fontData->glyph);
return fontDecompressor->getBitmap(fontData, glyph, glyphIndex);
}
return &fontData->bitmap[glyph->dataOffset];
}
void GfxRenderer::begin() {
frameBuffer = display.getFrameBuffer();
if (!frameBuffer) {
@@ -801,14 +813,14 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
continue;
}
const int is2Bit = font.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const EpdFontData* fontData = font.getData(style);
const int is2Bit = fontData->is2Bit;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
@@ -818,7 +830,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
// 90° clockwise rotation transformation:
// screenX = x + (ascender - top + glyphY)
// screenY = yPos - (left + glyphX)
const int screenX = x + (font.getData(style)->ascender - top + glyphY);
const int screenX = x + (fontData->ascender - top + glyphY);
const int screenY = yPos - left - glyphX;
if (is2Bit) {
@@ -966,14 +978,13 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
return;
}
const int is2Bit = fontFamily.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const EpdFontData* fontData = fontFamily.getData(style);
const int is2Bit = fontData->is2Bit;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const uint8_t* bitmap = nullptr;
bitmap = &fontFamily.getData(style)->bitmap[offset];
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {