|
| 1 | +# FontManager |
| 2 | + |
| 3 | +FontManager is a singleton framework for loading, caching, and composing LVGL fonts — including built-in bitmap fonts, TrueType fonts, and emoji image fonts. It automatically selects the best-quality pre-rendered emoji asset for the requested size and uses LVGL's imgfont fallback mechanism to render emoji inline with text. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +FontManager centralizes all font concerns in a single class: |
| 8 | + |
| 9 | +- **Unified API** - One call (`getFont`) to get any font, with or without emoji support |
| 10 | +- **Emoji Compositing** - Transparently layers an emoji imgfont on top of any base font via LVGL's `fallback` mechanism |
| 11 | +- **Size-tiered Assets** - Picks the closest pre-rendered emoji PNG directory so LVGL never has to scale further than necessary |
| 12 | +- **Lazy Caching** - Fonts and scaled image descriptors are cached on first use; no redundant work on subsequent calls |
| 13 | +- **Android-Inspired** - Follows the same singleton/class-method pattern as other MicroPythonOS frameworks |
| 14 | + |
| 15 | +## Quick Start |
| 16 | + |
| 17 | +```python |
| 18 | +from mpos import FontManager |
| 19 | + |
| 20 | +# Get a built-in Montserrat font at 16px, with emoji support (default) |
| 21 | +font = FontManager.getFont(size=16, family="Montserrat") |
| 22 | + |
| 23 | +# Get the same font without emoji (e.g. for glyph enumeration) |
| 24 | +base_font = FontManager.getFont(size=16, family="Montserrat", emoji=False) |
| 25 | + |
| 26 | +# Load a TrueType font from a file |
| 27 | +ttf_font = FontManager.getFont(size=42, ttf="M:apps/com.myapp/assets/MyFont.ttf") |
| 28 | + |
| 29 | +# List all available built-in fonts (each already has emoji composed in) |
| 30 | +for info in FontManager.listFonts(): |
| 31 | + print(info["name"], info["size"]) |
| 32 | + |
| 33 | +# Get all available emoji codepoints (union across all size tiers) |
| 34 | +for cp in FontManager.getEmojiCodepoints(): |
| 35 | + print(hex(cp)) |
| 36 | +``` |
| 37 | + |
| 38 | +## Architecture |
| 39 | + |
| 40 | +FontManager is implemented as a singleton using class variables and class methods. No instance creation is needed. |
| 41 | + |
| 42 | +### Font composition |
| 43 | + |
| 44 | +When `emoji=True` (the default), `getFont()` wraps the requested base font in an LVGL imgfont that renders emoji as scaled PNG images. The imgfont's `fallback` is set to the base font, so LVGL automatically falls through to the base font for any codepoint that is not an emoji. |
| 45 | + |
| 46 | +``` |
| 47 | +getFont(size=16) |
| 48 | + └── base_font = font_montserrat_16 (builtin bitmap) |
| 49 | + └── emoji_font = lv.imgfont_create(...) (PNG-based imgfont) |
| 50 | + └── emoji_font.fallback = base_font |
| 51 | + └── returns emoji_font |
| 52 | +``` |
| 53 | + |
| 54 | +### Emoji size tiers |
| 55 | + |
| 56 | +Emoji PNGs are stored in size-specific directories under `builtin/res/emojis/`: |
| 57 | + |
| 58 | +| Directory | Max target height | Used for | |
| 59 | +|-----------|------------------|----------| |
| 60 | +| `20x20/` | ≤ 20 px | Montserrat 8–16 (line heights ≤ 20 px) | |
| 61 | +| `56x56/` | any | Larger fonts | |
| 62 | + |
| 63 | +`_imgfont_path_cb` receives the rendering font's pixel height and picks the smallest tier whose `max_height` is ≥ the target. This minimises (or eliminates) the nearest-neighbour downscale performed by LVGL's software renderer. |
| 64 | + |
| 65 | +### Caching layers |
| 66 | + |
| 67 | +| Cache | Key | Value | |
| 68 | +|-------|-----|-------| |
| 69 | +| `_composed_font_cache` | `(font_id, emoji_size)` | Composed imgfont object | |
| 70 | +| `_ttf_font_cache` | `(path, size)` | `lv.tiny_ttf_create_file` result | |
| 71 | +| `_emoji_maps` | `dir_name` | `{codepoint: src_path}` dict | |
| 72 | +| `_imgfont_scaled_src_cache` | `(src, target_height)` | Scaled `lv.image_dsc_t` or original src | |
| 73 | +| `_imgfont_source_size_cache` | `src` | `(width, height)` tuple | |
| 74 | +| `_imgfont_empty_src_cache` | `target_height` | 1×h transparent `lv.image_dsc_t` | |
| 75 | + |
| 76 | +## API Reference |
| 77 | + |
| 78 | +### `getFont(size=None, ttf=None, family=None, emoji=True)` |
| 79 | + |
| 80 | +Return a font object suitable for use with `set_style_text_font()`. |
| 81 | + |
| 82 | +**Parameters:** |
| 83 | + |
| 84 | +- `size` (int, optional): Target pixel size. Snapped to the nearest available builtin size. Defaults to 12. |
| 85 | +- `ttf` (str, optional): Path to a `.ttf` file (e.g. `"M:apps/myapp/assets/MyFont.ttf"`). When provided, `family` is ignored. |
| 86 | +- `family` (str, optional): Font family name — `"Montserrat"` (default) or `"Unscii"`. |
| 87 | +- `emoji` (bool): If `True` (default), the returned font transparently renders emoji via a PNG imgfont fallback. Pass `False` to get the raw base font (useful for `get_glyph_dsc` enumeration). |
| 88 | + |
| 89 | +**Returns:** `lv.font_t` — the requested font, possibly wrapped with emoji support. |
| 90 | + |
| 91 | +**Example:** |
| 92 | + |
| 93 | +```python |
| 94 | +from mpos import FontManager |
| 95 | +import lvgl as lv |
| 96 | + |
| 97 | +font = FontManager.getFont(size=24, family="Montserrat") |
| 98 | +label = lv.label(screen) |
| 99 | +label.set_style_text_font(font, lv.PART.MAIN) |
| 100 | +label.set_text("Hello ❤️ 😀") |
| 101 | +``` |
| 102 | + |
| 103 | +--- |
| 104 | + |
| 105 | +### `listFonts()` |
| 106 | + |
| 107 | +Return a list of all available built-in fonts, each already composed with emoji support. |
| 108 | + |
| 109 | +**Returns:** list of dicts, each with keys: |
| 110 | + |
| 111 | +- `"name"` (str): Human-readable name, e.g. `"Montserrat 16"` |
| 112 | +- `"family"` (str): Family name, e.g. `"Montserrat"` |
| 113 | +- `"size"` (int): Nominal point size |
| 114 | +- `"font"` (lv.font_t): Composed font (with emoji) |
| 115 | +- `"base_font"` (lv.font_t): Raw base font (without emoji) |
| 116 | + |
| 117 | +**Example:** |
| 118 | + |
| 119 | +```python |
| 120 | +from mpos import FontManager |
| 121 | +import lvgl as lv |
| 122 | + |
| 123 | +for info in FontManager.listFonts(): |
| 124 | + label = lv.label(screen) |
| 125 | + label.set_style_text_font(info["font"], lv.PART.MAIN) |
| 126 | + label.set_text(info["name"] + ": ABC 😀 ❤️") |
| 127 | +``` |
| 128 | + |
| 129 | +--- |
| 130 | + |
| 131 | +### `getEmojiCodepoints()` |
| 132 | + |
| 133 | +Return a sorted list of all emoji codepoints available across all size tiers. |
| 134 | + |
| 135 | +**Returns:** list of int |
| 136 | + |
| 137 | +**Example:** |
| 138 | + |
| 139 | +```python |
| 140 | +from mpos import FontManager |
| 141 | + |
| 142 | +for cp in FontManager.getEmojiCodepoints(): |
| 143 | + print(hex(cp), chr(cp)) |
| 144 | +``` |
| 145 | + |
| 146 | +--- |
| 147 | + |
| 148 | +### `normalizeEmojiText(text)` |
| 149 | + |
| 150 | +Strip Unicode variation selectors (U+FE0E text selector, U+FE0F emoji selector) from a string. Useful before storing or comparing text that may have been pasted from a source that appends these codepoints. |
| 151 | + |
| 152 | +**Parameters:** |
| 153 | + |
| 154 | +- `text` (str): Input string |
| 155 | + |
| 156 | +**Returns:** str — cleaned string |
| 157 | + |
| 158 | +**Example:** |
| 159 | + |
| 160 | +```python |
| 161 | +from mpos import FontManager |
| 162 | + |
| 163 | +clean = FontManager.normalizeEmojiText("❤️") # removes U+FE0F after ❤ |
| 164 | +``` |
| 165 | + |
| 166 | +## Emoji Assets |
| 167 | + |
| 168 | +Emoji PNGs are stored in `internal_filesystem/builtin/res/emojis/` and are included in the firmware image at `/builtin/res/emojis/` on the device filesystem. Each subdirectory is a size tier: |
| 169 | + |
| 170 | +``` |
| 171 | +builtin/res/emojis/ |
| 172 | +├── 20x20/ # Pre-rendered at 20×20 px (Lanczos-downscaled from 56×56) |
| 173 | +│ ├── 1F600.png |
| 174 | +│ ├── 263A.png |
| 175 | +│ └── ... |
| 176 | +└── 56x56/ # Cropped from original 72×72 OpenMoji PNGs |
| 177 | + ├── 1F600.png |
| 178 | + ├── 263A.png |
| 179 | + └── ... |
| 180 | +``` |
| 181 | + |
| 182 | +Files are named by their Unicode codepoint in uppercase hex (e.g. `1F600.png` for 😀). FontManager scans each directory at runtime and builds a `{codepoint: path}` map. |
| 183 | + |
| 184 | +### Adding new emoji |
| 185 | + |
| 186 | +1. Add a PNG named `<CODEPOINT_HEX>.png` to both `56x56/` and `20x20/` (generate the 20×20 version with ImageMagick): |
| 187 | + |
| 188 | +```bash |
| 189 | +convert original.png -gravity center -crop 56x56+0+0 +repage -filter Lanczos -resize 20x20 20x20/CODEPOINT.png |
| 190 | +``` |
| 191 | + |
| 192 | +2. The new emoji will be picked up automatically on next boot — no code changes needed. |
| 193 | + |
| 194 | +## File Structure |
| 195 | + |
| 196 | +``` |
| 197 | +internal_filesystem/ |
| 198 | +├── lib/mpos/ui/ |
| 199 | +│ └── font_manager.py # FontManager class |
| 200 | +└── builtin/res/emojis/ |
| 201 | + ├── 20x20/ # 20×20 px emoji PNGs |
| 202 | + └── 56x56/ # 56×56 px emoji PNGs |
| 203 | +``` |
| 204 | + |
| 205 | +## Related Frameworks |
| 206 | + |
| 207 | +- **[AppearanceManager](appearance-manager.md)** - Theme colors and light/dark mode |
| 208 | +- **[DisplayMetrics](display-metrics.md)** - Display width, height, DPI |
| 209 | + |
| 210 | +## See Also |
| 211 | + |
| 212 | +- [Architecture Overview](../architecture/overview.md) |
| 213 | +- [Creating Apps](../apps/creating-apps.md) |
0 commit comments