From 7993b2bb976b2447c4a5221fc415f1f284e5f840 Mon Sep 17 00:00:00 2001 From: Adrian Wilkins-Caruana Date: Fri, 8 May 2026 21:36:35 -0500 Subject: [PATCH] feat: add SD card font support with on-device download and web management Add a complete SD card font subsystem that enables users to install and use custom fonts beyond the three built-in families. This combines the back-end firmware support (#1327) with the font configuration, build pipeline, CI distribution, and user-facing management UI (#1392). Core font system: - Custom .cpfont binary format (v4) with multi-style support (regular, bold, italic, bold-italic) packed into a single file per size - On-demand glyph loading from SD card with two-pass prewarm rendering to bulk-read glyphs per page, achieving near-flash performance for Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower) - Persistent advance cache for layout measurement without SD I/O - Overflow ring buffer for glyph cache misses during rendering - Memory-conscious design: only advance tables kept in RAM; glyph bitmaps, kern tables, and ligatures loaded on demand from SD Font management: - On-device WiFi download from GitHub Releases with manifest-based discovery, install/update detection, and progress UI - Web interface font upload, listing, and deletion via /fonts page - Manual SD card copy to /fonts/ or /.fonts/ directories - Font selection integrated into Settings > Reader > Font Family Build pipeline: - Declarative YAML config (sd-fonts.yaml) as single source of truth for the 17-family font library (serif, sans, mono, accessibility) - Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with FreeType rasterization, class-based kerning, and ligature extraction - Parallel build orchestrator with variable font instance extraction - CI workflow publishing versioned + stable releases to a dedicated crosspoint-fonts repository with auto-incrementing revision tags - Centralized version constants (cpfont_version.py) shared across build tooling and CI, with firmware headers as manual sync points Additional fixes: - CJK characters no longer get hyphens inserted at line breaks - Advance table eliminates 30+ second stalls during CJK section indexing for paragraphs with >512 unique codepoints Closes #930 Co-authored-by: Zach Nelson Co-authored-by: Justin Co-authored-by: jpirnay Co-authored-by: mcrosson --- .github/workflows/release-fonts.yml | 106 ++ .gitignore | 4 + docs/sd-card-fonts.md | 94 ++ lib/EpdFont/EpdFont.cpp | 32 +- lib/EpdFont/EpdFontData.h | 12 + lib/EpdFont/FontDecompressor.cpp | 37 + lib/EpdFont/SdCardFont.cpp | 1295 +++++++++++++++++ lib/EpdFont/SdCardFont.h | 241 +++ lib/EpdFont/SdCardFontManager.cpp | 98 ++ lib/EpdFont/SdCardFontManager.h | 50 + lib/EpdFont/SdCardFontRegistry.cpp | 230 +++ lib/EpdFont/SdCardFontRegistry.h | 58 + lib/EpdFont/builtinFonts/source/.gitignore | 12 + lib/EpdFont/scripts/build-sd-fonts.py | 331 +++++ lib/EpdFont/scripts/cpfont_version.py | 15 + lib/EpdFont/scripts/fontconvert_sdcard.py | 898 ++++++++++++ lib/EpdFont/scripts/sd-fonts.yaml | 211 +++ lib/Epub/Epub/ParsedText.cpp | 31 + lib/Epub/Epub/hyphenation/Hyphenator.cpp | 13 +- lib/GfxRenderer/FontCacheManager.cpp | 25 +- lib/GfxRenderer/FontCacheManager.h | 4 +- lib/GfxRenderer/GfxRenderer.cpp | 60 +- lib/GfxRenderer/GfxRenderer.h | 22 + lib/I18n/translations/english.yaml | 15 + lib/Utf8/Utf8.h | 18 + scripts/generate-font-manifest.py | 255 ++++ src/CrossPointSettings.cpp | 20 + src/CrossPointSettings.h | 11 +- src/FontInstaller.cpp | 156 ++ src/FontInstaller.h | 59 + src/JsonSettingsIO.cpp | 17 + src/SdCardFontGlobals.h | 12 + src/SdCardFontSystem.cpp | 109 ++ src/SdCardFontSystem.h | 52 + src/SettingsList.h | 104 +- src/activities/ActivityManager.cpp | 2 + src/activities/reader/EpubReaderActivity.cpp | 14 +- .../settings/FontDownloadActivity.cpp | 426 ++++++ .../settings/FontDownloadActivity.h | 91 ++ .../settings/FontSelectionActivity.cpp | 126 ++ .../settings/FontSelectionActivity.h | 34 + src/activities/settings/SettingsActivity.cpp | 73 +- src/activities/settings/SettingsActivity.h | 3 + src/components/themes/BaseTheme.cpp | 13 +- src/components/themes/BaseTheme.h | 4 +- src/components/themes/lyra/LyraTheme.cpp | 12 +- src/components/themes/lyra/LyraTheme.h | 2 +- .../themes/roundedraff/RoundedRaffTheme.cpp | 4 +- .../themes/roundedraff/RoundedRaffTheme.h | 4 +- src/fontIds.h | 18 + src/main.cpp | 10 +- src/network/CrossPointWebServer.cpp | 227 ++- src/network/CrossPointWebServer.h | 22 + src/network/html/FilesPage.html | 1 + src/network/html/FontsPage.html | 323 ++++ src/network/html/HomePage.html | 1 + src/network/html/SettingsPage.html | 1 + 57 files changed, 6064 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/release-fonts.yml create mode 100644 docs/sd-card-fonts.md create mode 100644 lib/EpdFont/SdCardFont.cpp create mode 100644 lib/EpdFont/SdCardFont.h create mode 100644 lib/EpdFont/SdCardFontManager.cpp create mode 100644 lib/EpdFont/SdCardFontManager.h create mode 100644 lib/EpdFont/SdCardFontRegistry.cpp create mode 100644 lib/EpdFont/SdCardFontRegistry.h create mode 100644 lib/EpdFont/builtinFonts/source/.gitignore create mode 100755 lib/EpdFont/scripts/build-sd-fonts.py create mode 100644 lib/EpdFont/scripts/cpfont_version.py create mode 100755 lib/EpdFont/scripts/fontconvert_sdcard.py create mode 100644 lib/EpdFont/scripts/sd-fonts.yaml create mode 100755 scripts/generate-font-manifest.py create mode 100644 src/FontInstaller.cpp create mode 100644 src/FontInstaller.h create mode 100644 src/SdCardFontGlobals.h create mode 100644 src/SdCardFontSystem.cpp create mode 100644 src/SdCardFontSystem.h create mode 100644 src/activities/settings/FontDownloadActivity.cpp create mode 100644 src/activities/settings/FontDownloadActivity.h create mode 100644 src/activities/settings/FontSelectionActivity.cpp create mode 100644 src/activities/settings/FontSelectionActivity.h create mode 100644 src/network/html/FontsPage.html diff --git a/.github/workflows/release-fonts.yml b/.github/workflows/release-fonts.yml new file mode 100644 index 00000000..0fcf9874 --- /dev/null +++ b/.github/workflows/release-fonts.yml @@ -0,0 +1,106 @@ +name: Build & Publish SD Card Fonts + +# Fonts change rarely — run manually when font sources or the conversion +# pipeline are updated. Publishes .cpfont files + fonts.json manifest as +# GitHub Release assets on the crosspoint-fonts repo so font releases don't +# clutter the firmware releases page. +# +# Requires a repository secret FONTS_REPO_TOKEN — a fine-grained PAT (or +# classic PAT) with contents:write permission on the target fonts repo. +on: + workflow_dispatch: + +env: + FONTS_REPO: crosspoint-reader/crosspoint-fonts + +jobs: + build-fonts: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Install font tools + run: pip install freetype-py fonttools pyyaml + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libfreetype6-dev + + - name: Read version constants + id: versions + run: | + cd lib/EpdFont/scripts + echo "binary=$(python3 -c 'from cpfont_version import CPFONT_VERSION; print(CPFONT_VERSION)')" >> "$GITHUB_OUTPUT" + echo "metadata=$(python3 -c 'from cpfont_version import FONTS_MANIFEST_VERSION; print(FONTS_MANIFEST_VERSION)')" >> "$GITHUB_OUTPUT" + + - name: Build SD card fonts + run: python3 lib/EpdFont/scripts/build-sd-fonts.py --clean + + - name: Flatten output for release assets + run: | + mkdir -p dist + find lib/EpdFont/scripts/output -name '*.cpfont' -exec cp {} dist/ \; + + - name: Compute release tags + id: tags + env: + GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }} + run: | + BASE="sd-fonts-m${{ steps.versions.outputs.metadata }}-b${{ steps.versions.outputs.binary }}" + echo "base=$BASE" >> "$GITHUB_OUTPUT" + + # Find the highest existing revision for this m/b pair + LAST=$(gh release list --repo "${{ env.FONTS_REPO }}" \ + --json tagName --jq \ + '[.[] | select(.tagName | startswith("'"${BASE}-r"'")) | .tagName | split("-r")[1] | tonumber] | max // 0') + NEXT=$((LAST + 1)) + echo "revision=$NEXT" >> "$GITHUB_OUTPUT" + echo "versioned=${BASE}-r${NEXT}" >> "$GITHUB_OUTPUT" + + - name: Generate manifest + run: | + python3 scripts/generate-font-manifest.py \ + --input dist \ + --base-url "https://github.com/${{ env.FONTS_REPO }}/releases/download/${{ steps.tags.outputs.base }}/" \ + --output dist/fonts.json \ + --descriptions-from lib/EpdFont/scripts/sd-fonts.yaml + + - name: Publish versioned release to fonts repo + env: + GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }} + run: | + VERSIONED="${{ steps.tags.outputs.versioned }}" + TITLE="SD Card Fonts (${VERSIONED#sd-fonts-})" + + gh release create "$VERSIONED" dist/* \ + --repo "${{ env.FONTS_REPO }}" \ + --title "$TITLE" \ + --notes "Pre-built \`.cpfont\` font files for CrossPoint Reader. + + Download individual files or use **Settings > System > Download Fonts** on the device. + + See [SD Card Fonts documentation](https://github.com/${{ github.repository }}/blob/main/docs/sd-card-fonts.md) for details." + + - name: Update stable tag for device downloads + env: + GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }} + run: | + BASE="${{ steps.tags.outputs.base }}" + + # Delete the old stable release for this m/b pair (the versioned releases are kept) + gh release delete "$BASE" --repo "${{ env.FONTS_REPO }}" --yes 2>/dev/null || true + + gh release create "$BASE" dist/* \ + --repo "${{ env.FONTS_REPO }}" \ + --title "SD Card Fonts (${BASE#sd-fonts-})" \ + --notes "Current font build for manifest v${{ steps.versions.outputs.metadata }}, binary format v${{ steps.versions.outputs.binary }}. Devices with this firmware version download from this release. + + This is revision **${{ steps.tags.outputs.revision }}** — see [\`${{ steps.tags.outputs.versioned }}\`](https://github.com/${{ env.FONTS_REPO }}/releases/tag/${{ steps.tags.outputs.versioned }}) for the immutable copy. + + Download individual files or use **Settings > System > Download Fonts** on the device." diff --git a/.gitignore b/.gitignore index f4bbcdfc..088d756c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ build .history/ /.venv *.local* +*.cpfont +lib/EpdFont/scripts/downloaded_fonts/ +lib/EpdFont/scripts/instanced_fonts/ +lib/EpdFont/scripts/output/ diff --git a/docs/sd-card-fonts.md b/docs/sd-card-fonts.md new file mode 100644 index 00000000..39f56e5e --- /dev/null +++ b/docs/sd-card-fonts.md @@ -0,0 +1,94 @@ +# SD Card Fonts + +CrossPoint supports loading additional fonts from the SD card, including fonts +with extended Unicode coverage (CJK, Cyrillic, Greek, etc.). + +## Installing Fonts + +There are three ways to install fonts: + +### Option 1: Download from device (recommended) + +1. Connect your CrossPoint reader to WiFi +2. Go to **Settings > System > Download Fonts** +3. Browse available font families and tap to download +4. Downloaded fonts appear immediately in **Settings > Reader > Font Family** + +### Option 2: Upload via web browser + +1. Connect your CrossPoint reader to WiFi +2. Open the web interface in your browser (shown on the WiFi screen) +3. Navigate to the **Fonts** tab +4. Upload `.cpfont` files using the upload form + +### Option 3: Manual SD card copy + +1. Download font files from the + [Releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases/tag/sd-fonts) +2. Copy font family folders to `/.crosspoint/fonts/` on your SD card: + + SD Card Root/ + └── .crosspoint/ + └── fonts/ + ├── Bookerly-SD/ + │ ├── Bookerly-SD_12.cpfont + │ ├── Bookerly-SD_14.cpfont + │ ├── Bookerly-SD_16.cpfont + │ └── Bookerly-SD_18.cpfont + └── ... + +3. Insert the SD card and power on your CrossPoint reader + +## Available Pre-Built Fonts + +| Font | Best For | Languages | +|------|----------|-----------| +| Bookerly-SD | General reading | English, Western European | +| NotoSansExtended | Multi-script reading | European, Greek, Cyrillic, Georgian, Armenian, Ethiopic | +| NotoSansCJK | Chinese/Japanese/Korean | CJK + ASCII | + +## Converting Custom Fonts + +To convert your own TrueType/OpenType fonts: + +### Prerequisites + + pip install freetype-py fonttools + +### Single font (one style) + + python3 lib/EpdFont/scripts/fontconvert_sdcard.py \ + MyFont-Regular.ttf \ + --intervals latin-ext \ + --sizes 12,14,16,18 \ + --style regular \ + --name MyFont \ + --output-dir ./MyFont/ + +### Multi-style font + + python3 lib/EpdFont/scripts/fontconvert_sdcard.py \ + --regular MyFont-Regular.ttf \ + --bold MyFont-Bold.ttf \ + --italic MyFont-Italic.ttf \ + --bolditalic MyFont-BoldItalic.ttf \ + --intervals latin-ext \ + --sizes 12,14,16,18 \ + --name MyFont \ + --output-dir ./MyFont/ + +### Available Unicode interval presets + +| Preset | Coverage | +|--------|----------| +| `ascii` | U+0020-U+007E (Basic Latin) | +| `latin-ext` | European languages (Latin + Extended-A/B) | +| `greek` | Greek + Extended Greek | +| `cyrillic` | Cyrillic + Supplement | +| `cjk` | CJK Unified Ideographs + Hiragana + Katakana + Fullwidth | +| `hangul` | Korean Hangul syllables | +| `builtin` | Matches built-in Bookerly coverage exactly | + +Combine presets with commas: `--intervals latin-ext,greek,cyrillic` + +Install custom fonts via WiFi upload or manual SD card copy. diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index fbcc3299..599554b7 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -153,24 +153,32 @@ uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const { const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const { const int count = data->intervalCount; - if (count == 0) return nullptr; + if (count == 0 && !data->glyphMissHandler) return nullptr; - const EpdUnicodeInterval* intervals = data->intervals; - const auto* end = intervals + count; + if (count > 0) { + const EpdUnicodeInterval* intervals = data->intervals; + const auto* end = intervals + count; - // upper_bound: range lookup. Finds the first interval with first > cp, so the - // interval just before it is the last one with first <= cp. That's the only - // candidate that could contain cp. Then we verify cp <= candidate.last. - const auto it = std::upper_bound( - intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; }); + // upper_bound: range lookup. Finds the first interval with first > cp, so the + // interval just before it is the last one with first <= cp. That's the only + // candidate that could contain cp. Then we verify cp <= candidate.last. + const auto it = std::upper_bound( + intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; }); - if (it != intervals) { - const auto& interval = *(it - 1); - if (cp <= interval.last) { - return &data->glyph[interval.offset + (cp - interval.first)]; + if (it != intervals) { + const auto& interval = *(it - 1); + if (cp <= interval.last) { + return &data->glyph[interval.offset + (cp - interval.first)]; + } } } + // Codepoint not in interval table — try on-demand loading (SD card fonts). + if (data->glyphMissHandler) { + const EpdGlyph* loaded = data->glyphMissHandler(data->glyphMissCtx, cp); + if (loaded) return loaded; + } + if (cp != REPLACEMENT_GLYPH) { return getGlyph(REPLACEMENT_GLYPH); } diff --git a/lib/EpdFont/EpdFontData.h b/lib/EpdFont/EpdFontData.h index 380c5733..cb1d5250 100644 --- a/lib/EpdFont/EpdFontData.h +++ b/lib/EpdFont/EpdFontData.h @@ -129,4 +129,16 @@ typedef struct { uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols) const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none) uint32_t ligaturePairCount; ///< Number of entries in ligaturePairs + + /// On-demand glyph loading for fonts that don't keep all glyphs in RAM (e.g. SD card fonts). + /// Called by getGlyph() when a codepoint is not found in the interval table. + /// Returns a valid EpdGlyph* with correct metadata, or nullptr to fall back to the + /// replacement glyph. The returned pointer is valid until the next glyphMissHandler + /// call that causes a ring-buffer eviction — callers must consume it (measure or draw) + /// before requesting another missed glyph. + const EpdGlyph* (*glyphMissHandler)(void* ctx, uint32_t codepoint); + + /// Context pointer for glyphMissHandler (typically SdCardFont*). Also used by + /// GfxRenderer::getGlyphBitmap() to retrieve overflow bitmaps via SdCardFont. + void* glyphMissCtx; } EpdFontData; diff --git a/lib/EpdFont/FontDecompressor.cpp b/lib/EpdFont/FontDecompressor.cpp index 10af14df..adfbb628 100644 --- a/lib/EpdFont/FontDecompressor.cpp +++ b/lib/EpdFont/FontDecompressor.cpp @@ -284,6 +284,43 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8 } } + // Add ligature output glyphs: if both input codepoints of a ligature pair are + // in the needed set, the output glyph will be queried during rendering. + if (fontData->ligaturePairs && fontData->ligaturePairCount > 0) { + for (uint32_t li = 0; li < fontData->ligaturePairCount && glyphCount < MAX_PAGE_GLYPHS; li++) { + uint32_t leftCp = fontData->ligaturePairs[li].pair >> 16; + uint32_t rightCp = fontData->ligaturePairs[li].pair & 0xFFFF; + + int32_t leftIdx = findGlyphIndex(fontData, leftCp); + int32_t rightIdx = findGlyphIndex(fontData, rightCp); + if (leftIdx < 0 || rightIdx < 0) continue; + + // Check if both inputs are in neededGlyphs + bool hasLeft = false, hasRight = false; + for (uint16_t i = 0; i < glyphCount; i++) { + if (neededGlyphs[i] == static_cast(leftIdx)) hasLeft = true; + if (neededGlyphs[i] == static_cast(rightIdx)) hasRight = true; + if (hasLeft && hasRight) break; + } + if (!hasLeft || !hasRight) continue; + + int32_t outIdx = findGlyphIndex(fontData, fontData->ligaturePairs[li].ligatureCp); + if (outIdx < 0) continue; + + // Deduplicate + bool found = false; + for (uint16_t i = 0; i < glyphCount; i++) { + if (neededGlyphs[i] == static_cast(outIdx)) { + found = true; + break; + } + } + if (!found) { + neededGlyphs[glyphCount++] = static_cast(outIdx); + } + } + } + if (glyphCount == 0) return 0; // Step 2: Compute total buffer size and collect unique groups diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp new file mode 100644 index 00000000..2a3329da --- /dev/null +++ b/lib/EpdFont/SdCardFont.cpp @@ -0,0 +1,1295 @@ +#include "SdCardFont.h" + +#include +#include +#include + +#include +#include +#include +#include + +static_assert(sizeof(EpdGlyph) == 16, "EpdGlyph must be 16 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdUnicodeInterval) == 12, "EpdUnicodeInterval must be 12 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdKernClassEntry) == 3, "EpdKernClassEntry must be 3 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdLigaturePair) == 8, "EpdLigaturePair must be 8 bytes to match .cpfont file layout"); + +// FNV-1a hash for content-based font ID generation +static constexpr uint32_t FNV_OFFSET = 2166136261u; +static constexpr uint32_t FNV_PRIME = 16777619u; + +static uint32_t fnv1a(const uint8_t* data, size_t len, uint32_t hash = FNV_OFFSET) { + for (size_t i = 0; i < len; i++) { + hash ^= data[i]; + hash *= FNV_PRIME; + } + return hash; +} + +// .cpfont magic bytes +static constexpr char CPFONT_MAGIC[8] = {'C', 'P', 'F', 'O', 'N', 'T', '\0', '\0'}; +// CPFONT_VERSION is defined as a #define in SdCardFont.h so it can be +// stringified into FONT_MANIFEST_URL. +static constexpr uint32_t HEADER_SIZE = 32; +static constexpr uint32_t STYLE_TOC_ENTRY_SIZE = 32; + +// Helper to read little-endian values from byte buffer +static inline uint16_t readU16(const uint8_t* p) { return p[0] | (p[1] << 8); } +static inline int16_t readI16(const uint8_t* p) { return static_cast(p[0] | (p[1] << 8)); } +static inline uint32_t readU32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); } + +SdCardFont::~SdCardFont() { freeAll(); } + +// --- Per-style free/cleanup --- + +void SdCardFont::freeStyleMiniData(PerStyle& s) { + delete[] s.miniIntervals; + s.miniIntervals = nullptr; + delete[] s.miniGlyphs; + s.miniGlyphs = nullptr; + delete[] s.miniBitmap; + s.miniBitmap = nullptr; + s.miniIntervalCount = 0; + s.miniGlyphCount = 0; + freeStyleMiniKern(s); + memset(&s.miniData, 0, sizeof(s.miniData)); + s.epdFont.data = &s.stubData; +} + +void SdCardFont::freeStyleKernLigatureData(PerStyle& s) { + delete[] s.kernLeftClasses; + s.kernLeftClasses = nullptr; + delete[] s.kernRightClasses; + s.kernRightClasses = nullptr; + delete[] s.ligaturePairs; + s.ligaturePairs = nullptr; + s.kernLigLoaded = false; +} + +void SdCardFont::freeStyleMiniKern(PerStyle& s) { + delete[] s.miniKernLeftClasses; + s.miniKernLeftClasses = nullptr; + delete[] s.miniKernRightClasses; + s.miniKernRightClasses = nullptr; + delete[] s.miniKernMatrix; + s.miniKernMatrix = nullptr; + s.miniKernLeftEntryCount = 0; + s.miniKernRightEntryCount = 0; + s.miniKernLeftClassCount = 0; + s.miniKernRightClassCount = 0; +} + +void SdCardFont::freeStyleAll(PerStyle& s) { + freeStyleMiniData(s); + delete[] s.fullIntervals; + s.fullIntervals = nullptr; + freeStyleKernLigatureData(s); + s.present = false; +} + +// --- Global free/cleanup --- + +void SdCardFont::freeAll() { + clearOverflow(); + clearPersistentCache(); + for (uint8_t i = 0; i < MAX_STYLES; i++) { + freeStyleAll(styles_[i]); + } + styleCount_ = 0; + contentHash_ = 0; + loaded_ = false; +} + +void SdCardFont::clearOverflow() { + for (uint32_t i = 0; i < overflowCount_; i++) { + delete[] overflow_[i].bitmap; + overflow_[i].bitmap = nullptr; + overflow_[i].codepoint = 0; + } + overflowCount_ = 0; + overflowNext_ = 0; +} + +// --- Per-style kern/ligature --- + +void SdCardFont::applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const { + // Kern data uses the per-page mini tables (renumbered class IDs). The full + // kern matrix is never resident — see PerStyle::miniKernMatrix comment. + data.kernLeftClasses = s.miniKernLeftClasses; + data.kernRightClasses = s.miniKernRightClasses; + data.kernMatrix = s.miniKernMatrix; + data.kernLeftEntryCount = s.miniKernLeftEntryCount; + data.kernRightEntryCount = s.miniKernRightEntryCount; + data.kernLeftClassCount = s.miniKernLeftClassCount; + data.kernRightClassCount = s.miniKernRightClassCount; + // Ligatures are small (typically < 1KB) so they stay resident. + data.ligaturePairs = s.ligaturePairs; + data.ligaturePairCount = s.header.ligaturePairCount; +} + +bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { + if (s.kernLigLoaded) return true; + bool hasKern = s.header.kernLeftEntryCount > 0; + bool hasLig = s.header.ligaturePairCount > 0; + if (!hasKern && !hasLig) { + s.kernLigLoaded = true; + return true; + } + + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont for kern/lig: %s", filePath_); + return false; + } + + if (hasKern) { + // Load only the small class-lookup tables (~3KB each). The full matrix + // (~36KB contiguous for Literata) is built per-page from SD in + // buildMiniKernMatrix(). + s.kernLeftClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernLeftEntryCount]; + s.kernRightClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernRightEntryCount]; + + if (!s.kernLeftClasses || !s.kernRightClasses) { + LOG_ERR("SDCF", "Failed to allocate kern classes (%u+%u bytes)", s.header.kernLeftEntryCount * 3u, + s.header.kernRightEntryCount * 3u); + freeStyleKernLigatureData(s); + return false; + } + + if (!file.seekSet(s.kernLeftFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to kern data"); + freeStyleKernLigatureData(s); + return false; + } + size_t leftSz = s.header.kernLeftEntryCount * sizeof(EpdKernClassEntry); + size_t rightSz = s.header.kernRightEntryCount * sizeof(EpdKernClassEntry); + if (file.read(reinterpret_cast(s.kernLeftClasses), leftSz) != static_cast(leftSz) || + file.read(reinterpret_cast(s.kernRightClasses), rightSz) != static_cast(rightSz)) { + LOG_ERR("SDCF", "Failed to read kern classes"); + freeStyleKernLigatureData(s); + return false; + } + } + + if (hasLig) { + s.ligaturePairs = new (std::nothrow) EpdLigaturePair[s.header.ligaturePairCount]; + if (!s.ligaturePairs) { + LOG_ERR("SDCF", "Failed to allocate ligature pairs"); + freeStyleKernLigatureData(s); + return false; + } + if (!file.seekSet(s.ligatureFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to ligature data"); + freeStyleKernLigatureData(s); + return false; + } + size_t sz = s.header.ligaturePairCount * sizeof(EpdLigaturePair); + if (file.read(reinterpret_cast(s.ligaturePairs), sz) != static_cast(sz)) { + LOG_ERR("SDCF", "Failed to read ligature pairs"); + freeStyleKernLigatureData(s); + return false; + } + } + + s.kernLigLoaded = true; + + // Make ligatures visible to the stub (used when no mini data built yet). + // Kern stays nullptr on the stub — it is only wired in miniData via + // applyKernLigaturePointers() after buildMiniKernMatrix() runs. + s.stubData.ligaturePairs = s.ligaturePairs; + s.stubData.ligaturePairCount = s.header.ligaturePairCount; + + LOG_DBG("SDCF", "Kern classes + lig loaded: kernL=%u, kernR=%u, ligs=%u", s.header.kernLeftEntryCount, + s.header.kernRightEntryCount, s.header.ligaturePairCount); + return true; +} + +// --- Per-page mini kern matrix --- + +// Local copy of EpdFont.cpp's lookupKernClass (that one is file-static there). +// Returns the 1-based class ID for `cp`, or 0 if the codepoint has no kerning class. +static uint8_t miniLookupKernClass(const EpdKernClassEntry* entries, uint16_t count, uint32_t cp) { + if (!entries || count == 0 || cp > 0xFFFF) return 0; + const auto target = static_cast(cp); + const auto* end = entries + count; + const auto it = + std::lower_bound(entries, end, target, [](const EpdKernClassEntry& e, uint16_t v) { return e.codepoint < v; }); + return (it != end && it->codepoint == target) ? it->classId : 0; +} + +// Build a small per-page kern matrix containing ONLY the (leftClass, rightClass) +// pairs reachable from codepoints in the current text. Class IDs are renumbered +// to a dense 1..N range so the resulting matrix is usedLeft × usedRight (typical +// Latin page: ~25×25 bytes) instead of the font's full ~180×200 (~36KB). +// +// Correctness: EpdFont::getKerning only touches `kernLeftClasses` / +// `kernRightClasses` / `kernMatrix` / the count fields — we swap all of them to +// the mini versions together in applyKernLigaturePointers, so a codepoint not +// on this page simply returns class 0 (no kerning), which was the pre-existing +// behavior for any codepoint outside the kern classes. +bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount) { + freeStyleMiniKern(s); + if (!s.kernLeftClasses || !s.kernRightClasses || s.header.kernLeftEntryCount == 0 || + s.header.kernRightEntryCount == 0) { + return true; // font has no kern classes — nothing to build + } + + // Step 1: mark used left/right classes via a 256-wide bitmap (class IDs are uint8_t). + bool usedLeft[256] = {}; + bool usedRight[256] = {}; + for (uint32_t i = 0; i < cpCount; i++) { + uint8_t lc = miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, codepoints[i]); + if (lc) usedLeft[lc] = true; + uint8_t rc = miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]); + if (rc) usedRight[rc] = true; + } + + // Step 2: build renumber maps (oldClassId -> newClassId, 1-based) and + // reverse maps (newClassId -> oldClassId) for the SD read step. + uint8_t leftRenumber[256] = {}; + uint8_t rightRenumber[256] = {}; + uint8_t newToOldLeft[256] = {}; + uint8_t newToOldRight[256] = {}; + uint8_t numLeft = 0, numRight = 0; + for (int i = 1; i < 256; i++) { + if (usedLeft[i]) { + numLeft++; + leftRenumber[i] = numLeft; + newToOldLeft[numLeft] = static_cast(i); + } + if (usedRight[i]) { + numRight++; + rightRenumber[i] = numRight; + newToOldRight[numRight] = static_cast(i); + } + } + if (numLeft == 0 || numRight == 0) { + return true; // no kern pairs applicable on this page + } + + // Step 3: count how many codepoint→classId entries the mini class tables need. + // Each resident class table has one entry per kerned codepoint in the page. + uint16_t miniLeftCount = 0; + uint16_t miniRightCount = 0; + for (uint32_t i = 0; i < cpCount; i++) { + if (miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, codepoints[i]) != 0) miniLeftCount++; + if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++; + } + + // Step 4: allocate the three mini buffers. The matrix is <1KB in practice + // (<30 × <30 × 1 byte) so fragmentation is a non-issue. + const uint32_t matrixBytes = static_cast(numLeft) * numRight; + s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount]; + s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount]; + s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes]; + if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) { + LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u, + matrixBytes); + freeStyleMiniKern(s); + return false; + } + + // Step 5: populate mini class tables. `codepoints` is already sorted (see + // prewarm()) so the output is sorted by codepoint — required for binary + // search in lookupKernClass during render. + uint16_t lIdx = 0, rIdx = 0; + for (uint32_t i = 0; i < cpCount; i++) { + uint32_t cp = codepoints[i]; + if (cp > 0xFFFF) continue; // kern class entries are uint16_t + uint8_t lc = miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, cp); + if (lc) { + s.miniKernLeftClasses[lIdx].codepoint = static_cast(cp); + s.miniKernLeftClasses[lIdx].classId = leftRenumber[lc]; + lIdx++; + } + uint8_t rc = miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, cp); + if (rc) { + s.miniKernRightClasses[rIdx].codepoint = static_cast(cp); + s.miniKernRightClasses[rIdx].classId = rightRenumber[rc]; + rIdx++; + } + } + + // Step 6: read the full matrix's rows for each used left class, keep only + // columns for used right classes. One SD seek + one read per used left class; + // a row is kernRightClassCount bytes (~200 for Literata). + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont for mini kern: %s", filePath_); + freeStyleMiniKern(s); + return false; + } + + std::unique_ptr rowBuf(new (std::nothrow) int8_t[s.header.kernRightClassCount]); + if (!rowBuf) { + LOG_ERR("SDCF", "Failed to allocate row buffer (%u bytes)", s.header.kernRightClassCount); + freeStyleMiniKern(s); + return false; + } + + for (uint8_t newL = 1; newL <= numLeft; newL++) { + const uint8_t oldL = newToOldLeft[newL]; + const uint32_t rowFileOff = s.kernMatrixFileOffset + (oldL - 1u) * s.header.kernRightClassCount; + if (!file.seekSet(rowFileOff)) { + LOG_ERR("SDCF", "Failed to seek to kern row %u", oldL); + freeStyleMiniKern(s); + return false; + } + if (file.read(reinterpret_cast(rowBuf.get()), s.header.kernRightClassCount) != + static_cast(s.header.kernRightClassCount)) { + LOG_ERR("SDCF", "Failed to read kern row %u", oldL); + freeStyleMiniKern(s); + return false; + } + int8_t* miniRow = s.miniKernMatrix + (newL - 1u) * numRight; + for (uint8_t newR = 1; newR <= numRight; newR++) { + miniRow[newR - 1] = rowBuf[newToOldRight[newR] - 1u]; + } + } + + s.miniKernLeftEntryCount = lIdx; + s.miniKernRightEntryCount = rIdx; + s.miniKernLeftClassCount = numLeft; + s.miniKernRightClassCount = numRight; + + LOG_DBG("SDCF", "Built mini kern: %u×%u matrix (%u bytes, full was %u×%u = %u bytes)", numLeft, numRight, matrixBytes, + s.header.kernLeftClassCount, s.header.kernRightClassCount, + static_cast(s.header.kernLeftClassCount) * s.header.kernRightClassCount); + return true; +} + +// --- Glyph miss callback --- + +void SdCardFont::applyGlyphMissCallback(uint8_t styleIdx) { + overflowCtx_[styleIdx].self = this; + overflowCtx_[styleIdx].styleIdx = styleIdx; + + auto& s = styles_[styleIdx]; + s.stubData.glyphMissHandler = &SdCardFont::onGlyphMiss; + s.stubData.glyphMissCtx = &overflowCtx_[styleIdx]; +} + +// --- Compute per-style file offsets from a base data offset --- + +void SdCardFont::computeStyleFileOffsets(PerStyle& s, uint32_t baseOffset) { + s.intervalsFileOffset = baseOffset; + s.glyphsFileOffset = s.intervalsFileOffset + s.header.intervalCount * sizeof(EpdUnicodeInterval); + s.kernLeftFileOffset = s.glyphsFileOffset + s.header.glyphCount * sizeof(EpdGlyph); + s.kernRightFileOffset = s.kernLeftFileOffset + s.header.kernLeftEntryCount * sizeof(EpdKernClassEntry); + s.kernMatrixFileOffset = s.kernRightFileOffset + s.header.kernRightEntryCount * sizeof(EpdKernClassEntry); + s.ligatureFileOffset = + s.kernMatrixFileOffset + static_cast(s.header.kernLeftClassCount) * s.header.kernRightClassCount; + s.bitmapFileOffset = s.ligatureFileOffset + s.header.ligaturePairCount * sizeof(EpdLigaturePair); +} + +// --- Load --- + +bool SdCardFont::load(const char* path) { + freeAll(); + if (strlen(path) >= sizeof(filePath_)) { + LOG_ERR("SDCF", "Path too long (%zu bytes, max %zu)", strlen(path), sizeof(filePath_) - 1); + return false; + } + strncpy(filePath_, path, sizeof(filePath_) - 1); + filePath_[sizeof(filePath_) - 1] = '\0'; + + FsFile file; + if (!Storage.openFileForRead("SDCF", path, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont: %s", path); + return false; + } + + // Read and validate global header + uint8_t headerBuf[HEADER_SIZE]; + if (file.read(headerBuf, HEADER_SIZE) != HEADER_SIZE) { + LOG_ERR("SDCF", "Failed to read header"); + return false; + } + + if (memcmp(headerBuf, CPFONT_MAGIC, 8) != 0) { + LOG_ERR("SDCF", "Invalid magic bytes"); + return false; + } + + uint16_t fileVersion = readU16(headerBuf + 8); + if (fileVersion != CPFONT_VERSION) { + LOG_ERR("SDCF", "Unsupported version: %u (expected %u)", fileVersion, CPFONT_VERSION); + return false; + } + + // Begin content hash: accumulate global header + uint32_t hash = fnv1a(headerBuf, HEADER_SIZE); + + bool is2Bit = (readU16(headerBuf + 10) & 1) != 0; + + uint8_t styleCount = headerBuf[12]; + if (styleCount == 0 || styleCount > MAX_STYLES) { + LOG_ERR("SDCF", "Invalid style count: %u", styleCount); + return false; + } + + // Read style TOC + for (uint8_t i = 0; i < styleCount; i++) { + uint8_t tocBuf[STYLE_TOC_ENTRY_SIZE]; + if (file.read(tocBuf, STYLE_TOC_ENTRY_SIZE) != STYLE_TOC_ENTRY_SIZE) { + LOG_ERR("SDCF", "Failed to read style TOC entry %u", i); + freeAll(); + return false; + } + + // Accumulate TOC entry into content hash + hash = fnv1a(tocBuf, STYLE_TOC_ENTRY_SIZE, hash); + + uint8_t styleId = tocBuf[0]; + if (styleId >= MAX_STYLES) { + LOG_ERR("SDCF", "Invalid styleId %u in TOC", styleId); + file.close(); + freeAll(); + return false; + } + + auto& s = styles_[styleId]; + s.present = true; + s.header.intervalCount = readU32(tocBuf + 4); + s.header.glyphCount = readU32(tocBuf + 8); + s.header.advanceY = tocBuf[12]; + s.header.ascender = readI16(tocBuf + 13); + s.header.descender = readI16(tocBuf + 15); + s.header.kernLeftEntryCount = readU16(tocBuf + 17); + s.header.kernRightEntryCount = readU16(tocBuf + 19); + s.header.kernLeftClassCount = tocBuf[21]; + s.header.kernRightClassCount = tocBuf[22]; + s.header.ligaturePairCount = tocBuf[23]; + s.header.is2Bit = is2Bit; + + // Sanity-check counts to reject malformed files before allocating. + // Kern class counts are uint8 (bounded by type). Entry counts are uint16 + // but in practice a sane font has far fewer than 4096 per-side kern entries. + static constexpr uint32_t MAX_INTERVALS = 4096; + static constexpr uint32_t MAX_GLYPHS = 65536; + static constexpr uint32_t MAX_KERN_ENTRIES = 4096; + if (s.header.intervalCount > MAX_INTERVALS || s.header.glyphCount > MAX_GLYPHS || + s.header.kernLeftEntryCount > MAX_KERN_ENTRIES || s.header.kernRightEntryCount > MAX_KERN_ENTRIES) { + LOG_ERR("SDCF", "Style %u: unreasonable counts (iv=%u, gl=%u, kL=%u, kR=%u)", styleId, s.header.intervalCount, + s.header.glyphCount, s.header.kernLeftEntryCount, s.header.kernRightEntryCount); + file.close(); + freeAll(); + return false; + } + + uint32_t dataOffset = readU32(tocBuf + 24); + computeStyleFileOffsets(s, dataOffset); + } + + styleCount_ = styleCount; + contentHash_ = hash; + + // Load full intervals into RAM for each present style + for (uint8_t i = 0; i < MAX_STYLES; i++) { + auto& s = styles_[i]; + if (!s.present) continue; + + s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount]; + if (!s.fullIntervals) { + LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i); + freeAll(); + return false; + } + + if (!file.seekSet(s.intervalsFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i); + freeAll(); + return false; + } + size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval); + if (file.read(reinterpret_cast(s.fullIntervals), intervalsBytes) != static_cast(intervalsBytes)) { + LOG_ERR("SDCF", "Failed to read intervals for style %u", i); + freeAll(); + return false; + } + + // Validate interval contents before any later code (findGlobalGlyphIndex, + // glyph reads) trusts them. A malformed file could otherwise drive + // out-of-range glyph indices into bogus on-disk reads. + { + uint32_t expectedOffset = 0; + uint32_t prevLast = 0; + for (uint32_t j = 0; j < s.header.intervalCount; ++j) { + const auto& iv = s.fullIntervals[j]; + if (iv.first > iv.last) { + LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j, + static_cast(iv.first), static_cast(iv.last)); + file.close(); + freeAll(); + return false; + } + const uint32_t span = iv.last - iv.first + 1; + const bool overlapsPrev = (j > 0 && iv.first <= prevLast); + const bool spanTooBig = (span > s.header.glyphCount); + const bool offsetMismatch = (iv.offset != expectedOffset); + const bool offsetOverruns = (iv.offset > s.header.glyphCount - span); + if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) { + LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j, + overlapsPrev, span, offsetMismatch, offsetOverruns); + file.close(); + freeAll(); + return false; + } + expectedOffset += span; + prevLast = iv.last; + } + } + + // Initialize stub data + memset(&s.stubData, 0, sizeof(s.stubData)); + s.stubData.advanceY = s.header.advanceY; + s.stubData.ascender = s.header.ascender; + s.stubData.descender = s.header.descender; + s.stubData.is2Bit = s.header.is2Bit; + + s.epdFont.data = &s.stubData; + applyGlyphMissCallback(i); + } + + loaded_ = true; + + LOG_DBG("SDCF", "Loaded: %s (v%u, %u styles)", path, CPFONT_VERSION, styleCount_); + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (!styles_[i].present) continue; + const auto& h = styles_[i].header; + LOG_DBG("SDCF", " style[%u]: %u intervals, %u glyphs, advY=%u, asc=%d, desc=%d, kernL=%u, kernR=%u, ligs=%u", i, + h.intervalCount, h.glyphCount, h.advanceY, h.ascender, h.descender, h.kernLeftEntryCount, + h.kernRightEntryCount, h.ligaturePairCount); + } + return true; +} + +// --- Codepoint lookup --- + +int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const { + int left = 0; + int right = static_cast(s.header.intervalCount) - 1; + while (left <= right) { + int mid = left + (right - left) / 2; + const auto& interval = s.fullIntervals[mid]; + if (codepoint < interval.first) { + right = mid - 1; + } else if (codepoint > interval.last) { + left = mid + 1; + } else { + return static_cast(interval.offset + (codepoint - interval.first)); + } + } + return -1; +} + +// --- Prewarm --- + +int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOnly) { + if (!loaded_) return -1; + + unsigned long startMs = millis(); + + // Step 1: Extract unique codepoints from UTF-8 text (shared across all styles). + // Dedup uses O(n^2) linear scan — worst case is MAX_PAGE_GLYPHS (512) unique codepoints + // = ~131K comparisons, but in practice pages contain far fewer unique codepoints so the + // actual cost is much lower. This is dwarfed by SD I/O that follows. Alternatives (hash + // set, bitmap) exceed the 256-byte stack limit or add template bloat. + // Heap-allocated: MAX_PAGE_GLYPHS * 4 = 2048 bytes, too large for stack (limit < 256 bytes) + std::unique_ptr codepoints(new (std::nothrow) uint32_t[MAX_PAGE_GLYPHS]); + if (!codepoints) { + LOG_ERR("SDCF", "Failed to allocate codepoint buffer (%u bytes)", MAX_PAGE_GLYPHS * 4); + return -1; + } + uint32_t cpCount = 0; + + const unsigned char* p = reinterpret_cast(utf8Text); + while (*p && cpCount < MAX_PAGE_GLYPHS) { + uint32_t cp = utf8NextCodepoint(&p); + if (cp == 0) break; + + bool found = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == cp) { + found = true; + break; + } + } + if (!found) { + codepoints[cpCount++] = cp; + } + } + + // Always include the replacement character + { + bool hasReplacement = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == REPLACEMENT_GLYPH) { + hasReplacement = true; + break; + } + } + if (!hasReplacement && cpCount < MAX_PAGE_GLYPHS) { + codepoints[cpCount++] = REPLACEMENT_GLYPH; + } + } + + // Add ligature output codepoints from all styles being prewarmed. + // Skip during metadata-only prewarm (layout measurement) to avoid loading + // kern/lig data for all styles upfront (~22KB per style). Kern/lig is + // loaded per-style in prewarmStyle() during the full render prewarm instead. + if (!metadataOnly) { + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + auto& s = styles_[si]; + + loadStyleKernLigatureData(s); + if (s.ligaturePairs && s.header.ligaturePairCount > 0) { + for (uint8_t li = 0; li < s.header.ligaturePairCount && cpCount < MAX_PAGE_GLYPHS; li++) { + uint32_t leftCp = s.ligaturePairs[li].pair >> 16; + uint32_t rightCp = s.ligaturePairs[li].pair & 0xFFFF; + uint32_t outCp = s.ligaturePairs[li].ligatureCp; + + bool hasLeft = false, hasRight = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == leftCp) hasLeft = true; + if (codepoints[i] == rightCp) hasRight = true; + if (hasLeft && hasRight) break; + } + if (!hasLeft || !hasRight) continue; + + bool hasOut = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == outCp) { + hasOut = true; + break; + } + } + if (!hasOut) { + codepoints[cpCount++] = outCp; + } + } + } + } + } + + // Sort codepoints for ordered interval building + std::sort(codepoints.get(), codepoints.get() + cpCount); + + // Prewarm each requested style + int totalMissed = 0; + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + totalMissed += prewarmStyle(si, codepoints.get(), cpCount, metadataOnly); + } + + stats_.prewarmTotalMs = millis() - startMs; + return totalMissed; +} + +int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly) { + auto& s = styles_[styleIdx]; + + // Map codepoints to global glyph indices for this style + struct CpGlyphMapping { + uint32_t codepoint; + int32_t globalIndex; + }; + CpGlyphMapping* mappings = new (std::nothrow) CpGlyphMapping[cpCount]; + if (!mappings) { + LOG_ERR("SDCF", "Failed to allocate mapping array for style %u", styleIdx); + return static_cast(cpCount); + } + + uint32_t validCount = 0; + for (uint32_t i = 0; i < cpCount; i++) { + int32_t idx = findGlobalGlyphIndex(s, codepoints[i]); + if (idx >= 0) { + mappings[validCount].codepoint = codepoints[i]; + mappings[validCount].globalIndex = idx; + validCount++; + } + } + int missed = static_cast(cpCount - validCount); + + if (validCount == 0) { + freeStyleMiniData(s); + delete[] mappings; + s.epdFont.data = &s.stubData; + return missed; + } + + // Build mini intervals from sorted codepoints + freeStyleMiniData(s); + + uint32_t intervalCapacity = validCount; + s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity]; + if (!s.miniIntervals) { + LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx); + delete[] mappings; + return static_cast(cpCount); + } + + s.miniIntervalCount = 0; + uint32_t rangeStart = 0; + for (uint32_t i = 1; i <= validCount; i++) { + if (i == validCount || mappings[i].codepoint != mappings[i - 1].codepoint + 1) { + s.miniIntervals[s.miniIntervalCount].first = mappings[rangeStart].codepoint; + s.miniIntervals[s.miniIntervalCount].last = mappings[i - 1].codepoint; + s.miniIntervals[s.miniIntervalCount].offset = rangeStart; + s.miniIntervalCount++; + rangeStart = i; + } + } + + // Allocate mini glyph array + s.miniGlyphCount = validCount; + s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount]; + if (!s.miniGlyphs) { + LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + // Build sorted read order for sequential I/O + uint32_t* readOrder = new (std::nothrow) uint32_t[validCount]; + if (!readOrder) { + LOG_ERR("SDCF", "Failed to allocate read order for style %u", styleIdx); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + for (uint32_t i = 0; i < validCount; i++) readOrder[i] = i; + std::sort(readOrder, readOrder + validCount, + [&](uint32_t a, uint32_t b) { return mappings[a].globalIndex < mappings[b].globalIndex; }); + + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to reopen .cpfont for prewarm (style %u)", styleIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + unsigned long sdStart = millis(); + uint32_t seekCount = 0; + + // Read glyph metadata. lastReadIndex tracks sequential reads to skip redundant + // seeks; INT32_MIN guarantees the first iteration always seeks to the correct + // offset (otherwise when gIdx == 0, the "gIdx != lastReadIndex + 1" check would + // be false and we'd read from the file's current position — the header — which + // decodes to a garbage EpdGlyph with a massive advanceX, inflating any word + // containing that codepoint beyond page width). + int32_t lastReadIndex = INT32_MIN; + for (uint32_t i = 0; i < validCount; i++) { + uint32_t mapIdx = readOrder[i]; + int32_t gIdx = mappings[mapIdx].globalIndex; + + uint32_t fileOff = s.glyphsFileOffset + static_cast(gIdx) * sizeof(EpdGlyph); + if (gIdx != lastReadIndex + 1) { + if (!file.seekSet(fileOff)) { + LOG_ERR("SDCF", "Prewarm: failed to seek to glyph %d (style %u)", gIdx, styleIdx); + file.close(); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + seekCount++; + } + if (file.read(reinterpret_cast(&s.miniGlyphs[mapIdx]), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "Prewarm: short glyph read (style %u, glyph %d)", styleIdx, gIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + lastReadIndex = gIdx; + } + + uint32_t totalBitmapSize = 0; + + if (!metadataOnly) { + // Compute total bitmap size + for (uint32_t i = 0; i < validCount; i++) { + totalBitmapSize += s.miniGlyphs[i].dataLength; + } + + s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1]; + if (!s.miniBitmap) { + LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + // Read bitmap data sorted by file offset + std::sort(readOrder, readOrder + validCount, + [&](uint32_t a, uint32_t b) { return s.miniGlyphs[a].dataOffset < s.miniGlyphs[b].dataOffset; }); + + uint32_t miniBitmapOffset = 0; + uint32_t lastBitmapEnd = UINT32_MAX; + for (uint32_t i = 0; i < validCount; i++) { + uint32_t mapIdx = readOrder[i]; + EpdGlyph& glyph = s.miniGlyphs[mapIdx]; + + if (glyph.dataLength == 0) { + glyph.dataOffset = miniBitmapOffset; + continue; + } + + uint32_t fileOff = s.bitmapFileOffset + glyph.dataOffset; + if (fileOff != lastBitmapEnd) { + if (!file.seekSet(fileOff)) { + LOG_ERR("SDCF", "Prewarm: failed to seek to bitmap (style %u)", styleIdx); + file.close(); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + seekCount++; + } + if (file.read(s.miniBitmap + miniBitmapOffset, glyph.dataLength) != static_cast(glyph.dataLength)) { + LOG_ERR("SDCF", "Prewarm: short bitmap read (style %u)", styleIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + lastBitmapEnd = fileOff + glyph.dataLength; + + glyph.dataOffset = miniBitmapOffset; + miniBitmapOffset += glyph.dataLength; + } + } + + uint32_t sdTime = millis() - sdStart; + delete[] readOrder; + delete[] mappings; + + // Full render prewarm: load the persistent kern classes + ligatures (one-time + // per style, small — the big matrix is NOT loaded here) and then build the + // per-page mini kern matrix restricted to class pairs reachable from this + // page's codepoints. Skip during metadata-only prewarm — layout only needs + // advanceX and the mini kern would be thrown away before rendering. + bool kernLigOk = false; + if (!metadataOnly) { + if (loadStyleKernLigatureData(s)) { + kernLigOk = buildMiniKernMatrix(s, codepoints, cpCount); + } + } + + // Populate miniData and swap + memset(&s.miniData, 0, sizeof(s.miniData)); + s.miniData.bitmap = s.miniBitmap; + s.miniData.glyph = s.miniGlyphs; + s.miniData.intervals = s.miniIntervals; + s.miniData.intervalCount = s.miniIntervalCount; + s.miniData.advanceY = s.header.advanceY; + s.miniData.ascender = s.header.ascender; + s.miniData.descender = s.header.descender; + s.miniData.is2Bit = s.header.is2Bit; + if (kernLigOk) { + applyKernLigaturePointers(s, s.miniData); + } + s.miniData.glyphMissHandler = &SdCardFont::onGlyphMiss; + s.miniData.glyphMissCtx = &overflowCtx_[styleIdx]; + + s.epdFont.data = &s.miniData; + + // Accumulate stats + stats_.sdReadTimeMs += sdTime; + stats_.seekCount += seekCount; + stats_.uniqueGlyphs += validCount; + stats_.bitmapBytes += totalBitmapSize; + + return missed; +} + +// --- Cache management --- + +void SdCardFont::clearCache() { + clearOverflow(); + // Note: advance table is intentionally preserved here. It persists across + // layout passes so repeated section indexing amortizes SD reads. Use + // clearPersistentCache() to wipe it. + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (!styles_[i].present) continue; + freeStyleMiniData(styles_[i]); + applyGlyphMissCallback(i); + } +} + +// --- Advance table --- + +void SdCardFont::clearPersistentCache() { + for (uint8_t i = 0; i < MAX_STYLES; i++) { + delete[] advanceTable_[i]; + advanceTable_[i] = nullptr; + advanceTableSize_[i] = 0; + } +} + +bool SdCardFont::advanceTableLookup(uint8_t styleIdx, uint32_t codepoint, uint16_t* outAdvance) const { + const AdvanceEntry* table = advanceTable_[styleIdx]; + const uint32_t size = advanceTableSize_[styleIdx]; + if (!table || size == 0) return false; + uint32_t lo = 0, hi = size; + while (lo < hi) { + uint32_t mid = lo + (hi - lo) / 2; + if (table[mid].codepoint < codepoint) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < size && table[lo].codepoint == codepoint) { + if (outAdvance) *outAdvance = table[lo].advanceX; + return true; + } + return false; +} + +void SdCardFont::mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sortedNew, uint32_t newCount) { + if (newCount == 0) return; + const uint32_t oldSize = advanceTableSize_[styleIdx]; + if (oldSize >= ADVANCE_CACHE_LIMIT) return; // already full + + // Cap the merged size at ADVANCE_CACHE_LIMIT. Anything past the cap is + // dropped from the tail of the sorted merge — a deterministic, bounded loss + // that doesn't bias which codepoints get cached on subsequent passes. + uint32_t mergedCap = oldSize + newCount; + if (mergedCap > ADVANCE_CACHE_LIMIT) mergedCap = ADVANCE_CACHE_LIMIT; + + AdvanceEntry* merged = new (std::nothrow) AdvanceEntry[mergedCap]; + if (!merged) { + LOG_ERR("SDCF", "mergeIntoAdvanceTable: alloc failed (%u entries) style %u", mergedCap, styleIdx); + return; + } + + const AdvanceEntry* a = advanceTable_[styleIdx]; + const AdvanceEntry* b = sortedNew; + uint32_t i = 0, j = 0, k = 0; + while (k < mergedCap && (i < oldSize || j < newCount)) { + if (i < oldSize && (j >= newCount || a[i].codepoint <= b[j].codepoint)) { + merged[k++] = a[i++]; + } else { + merged[k++] = b[j++]; + } + } + + delete[] advanceTable_[styleIdx]; + advanceTable_[styleIdx] = merged; + advanceTableSize_[styleIdx] = k; +} + +bool SdCardFont::hasAdvanceTable() const { + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (advanceTable_[i]) return true; + } + return false; +} + +uint16_t SdCardFont::getAdvance(uint32_t codepoint, uint8_t style) const { + style &= (MAX_STYLES - 1); + if (!advanceTable_[style]) return 0; + const AdvanceEntry* table = advanceTable_[style]; + const uint32_t size = advanceTableSize_[style]; + // Binary search sorted by codepoint + uint32_t lo = 0, hi = size; + while (lo < hi) { + uint32_t mid = lo + (hi - lo) / 2; + if (table[mid].codepoint < codepoint) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < size && table[lo].codepoint == codepoint) { + return table[lo].advanceX; + } + return 0; +} + +int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask) { + if (!loaded_) return -1; + + // Note: advance table is preserved across calls. We only fetch codepoints + // not already present, then merge them in. Use clearPersistentCache() to + // wipe the table when the font/size/family changes. + + unsigned long startMs = millis(); + + // Step 1: Extract unique codepoints, capped at MAX_UNIQUE_CODEPOINTS. + // The dedup buffer is sized to the cap, not total chars — a large EPUB section + // may contain 50K+ characters but real text has far fewer unique codepoints. + // 4096 × 4 bytes = 16KB temporary; bounded regardless of input size. + static constexpr uint32_t MAX_UNIQUE_CODEPOINTS = 4096; + uint32_t* codepoints = new (std::nothrow) uint32_t[MAX_UNIQUE_CODEPOINTS]; + if (!codepoints) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate codepoint buffer (%u bytes)", MAX_UNIQUE_CODEPOINTS * 4); + return -1; + } + uint32_t cpCount = 0; + bool hitCap = false; + + // Second pass: collect unique codepoints via O(n²) dedup. + // Bounded by uniqueCount × totalChars comparisons. For 2000 unique from 2291 total, + // worst case ~4.6M comparisons of uint32_t — ~30ms on 160MHz RISC-V, acceptable + // for one-time section indexing. + const unsigned char* p = reinterpret_cast(utf8Text); + while (*p) { + uint32_t cp = utf8NextCodepoint(&p); + if (cp == 0) break; + + bool found = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == cp) { + found = true; + break; + } + } + if (!found) { + if (cpCount >= MAX_UNIQUE_CODEPOINTS) { + hitCap = true; + break; + } + codepoints[cpCount++] = cp; + } + } + if (hitCap) { + LOG_ERR("SDCF", "buildAdvanceTable: unique codepoint cap (%u) hit, layout may be approximate", + MAX_UNIQUE_CODEPOINTS); + } + + // Sort for ordered glyph index mapping and final table output + std::sort(codepoints, codepoints + cpCount); + + // Step 2: For each requested style, fetch any codepoints not yet cached and + // merge them into the persistent advance table. + int totalMissed = 0; + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + const auto& s = styles_[si]; + + // Stop fetching once the cache is full — further inserts would be dropped + // by the merge anyway. The renderer fast path tolerates missing entries + // (returns 0); the slow path is still correct for those codepoints. + if (advanceTableSize_[si] >= ADVANCE_CACHE_LIMIT) continue; + + // For each codepoint in `codepoints`, skip those already cached, then + // resolve to a glyph index. Build a parallel array sorted by glyph index + // for sequential SD reads. + struct CpIdx { + uint32_t codepoint; + int32_t glyphIndex; + }; + std::unique_ptr mappings(new (std::nothrow) CpIdx[cpCount]); + if (!mappings) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate mappings for style %u", si); + totalMissed += cpCount; + continue; + } + + uint32_t needCount = 0; + uint32_t missedThisStyle = 0; + for (uint32_t i = 0; i < cpCount; i++) { + const uint32_t cp = codepoints[i]; + if (advanceTableLookup(si, cp, nullptr)) continue; // already cached + int32_t idx = findGlobalGlyphIndex(s, cp); + if (idx < 0) { + missedThisStyle++; + continue; + } + mappings[needCount].codepoint = cp; + mappings[needCount].glyphIndex = idx; + needCount++; + } + totalMissed += static_cast(missedThisStyle); + + if (needCount == 0) continue; + + // Sort by glyph index so SD reads are mostly sequential. + std::sort(mappings.get(), mappings.get() + needCount, + [](const CpIdx& a, const CpIdx& b) { return a.glyphIndex < b.glyphIndex; }); + + // Open file once and read advanceX for each needed glyph. + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to open .cpfont for style %u", si); + continue; + } + + std::unique_ptr staged(new (std::nothrow) AdvanceEntry[needCount]); + if (!staged) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate staging for style %u", si); + file.close(); + continue; + } + + uint32_t fetched = 0; + EpdGlyph tempGlyph; + int32_t lastReadIndex = INT32_MIN; + for (uint32_t i = 0; i < needCount; i++) { + int32_t gIdx = mappings[i].glyphIndex; + uint32_t fileOff = s.glyphsFileOffset + static_cast(gIdx) * sizeof(EpdGlyph); + if (gIdx != lastReadIndex + 1) { + if (!file.seekSet(fileOff)) { + LOG_ERR("SDCF", "buildAdvanceTable: failed to seek to glyph %d (style %u)", gIdx, si); + break; + } + } + if (file.read(reinterpret_cast(&tempGlyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "buildAdvanceTable: short glyph read (style %u, glyph %d)", si, gIdx); + break; + } + lastReadIndex = gIdx; + staged[fetched].codepoint = mappings[i].codepoint; + staged[fetched].advanceX = tempGlyph.advanceX; + fetched++; + } + file.close(); + + if (fetched > 0) { + // Sort staged by codepoint, then merge into the persistent table. + std::sort(staged.get(), staged.get() + fetched, + [](const AdvanceEntry& a, const AdvanceEntry& b) { return a.codepoint < b.codepoint; }); + mergeIntoAdvanceTable(si, staged.get(), fetched); + } + + LOG_DBG("SDCF", "Advance table style %u: +%u from SD, total=%u/%u", si, fetched, advanceTableSize_[si], + ADVANCE_CACHE_LIMIT); + } + + delete[] codepoints; + + stats_.prewarmTotalMs = millis() - startMs; + return totalMissed; +} + +// --- Stats --- + +void SdCardFont::logStats(const char* label) { + LOG_DBG("SDCF", "[%s] total=%ums sd_read=%ums seeks=%u glyphs=%u bitmap=%u bytes", label, stats_.prewarmTotalMs, + stats_.sdReadTimeMs, stats_.seekCount, stats_.uniqueGlyphs, stats_.bitmapBytes); +} + +void SdCardFont::resetStats() { stats_ = Stats{}; } + +// --- Public accessors --- + +EpdFont* SdCardFont::getEpdFont(uint8_t style) { + style &= (MAX_STYLES - 1); + if (!styles_[style].present) return nullptr; + return &styles_[style].epdFont; +} + +bool SdCardFont::hasStyle(uint8_t style) const { return styles_[style & (MAX_STYLES - 1)].present; } + +// --- On-demand glyph loading (overflow buffer) --- + +const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { + auto* oc = static_cast(ctx); + auto* self = oc->self; + uint8_t styleIdx = oc->styleIdx; + + if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr; + const auto& s = self->styles_[styleIdx]; + if (!s.fullIntervals) return nullptr; + + // Check overflow cache first (matching both codepoint and style) + for (uint32_t i = 0; i < self->overflowCount_; i++) { + if (self->overflow_[i].codepoint == codepoint && self->overflow_[i].styleIdx == styleIdx) { + return &self->overflow_[i].glyph; + } + } + + // Look up global glyph index via full intervals + int32_t globalIdx = self->findGlobalGlyphIndex(s, codepoint); + if (globalIdx < 0) return nullptr; + + // Pick overflow slot (ring buffer). Read into temporaries first so the + // existing slot stays valid if SD I/O fails. Bookkeeping (count/next) + // is deferred until after all I/O succeeds to avoid inconsistent state. + uint32_t slot = self->overflowNext_; + bool wasAtCapacity = (self->overflowCount_ == OVERFLOW_CAPACITY); + + // Read glyph metadata into temporary + FsFile file; + if (!Storage.openFileForRead("SDCF", self->filePath_, file)) { + LOG_ERR("SDCF", "Overflow: failed to open .cpfont"); + return nullptr; + } + + EpdGlyph tempGlyph = {}; + uint32_t glyphFileOff = s.glyphsFileOffset + static_cast(globalIdx) * sizeof(EpdGlyph); + if (!file.seekSet(glyphFileOff)) { + LOG_ERR("SDCF", "Overflow: failed to seek to glyph for U+%04X style %u", codepoint, styleIdx); + file.close(); + return nullptr; + } + if (file.read(reinterpret_cast(&tempGlyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "Overflow: failed to read glyph metadata for U+%04X style %u", codepoint, styleIdx); + return nullptr; + } + + // Read bitmap data into temporary (if any) + uint8_t* tempBitmap = nullptr; + if (tempGlyph.dataLength > 0) { + tempBitmap = new (std::nothrow) uint8_t[tempGlyph.dataLength]; + if (!tempBitmap) { + LOG_ERR("SDCF", "Overflow: failed to allocate %u bytes for U+%04X bitmap", tempGlyph.dataLength, codepoint); + return nullptr; + } + if (!file.seekSet(s.bitmapFileOffset + tempGlyph.dataOffset)) { + LOG_ERR("SDCF", "Overflow: failed to seek to bitmap for U+%04X", codepoint); + delete[] tempBitmap; + file.close(); + return nullptr; + } + if (file.read(tempBitmap, tempGlyph.dataLength) != static_cast(tempGlyph.dataLength)) { + LOG_ERR("SDCF", "Overflow: failed to read bitmap for U+%04X", codepoint); + delete[] tempBitmap; + return nullptr; + } + } + + // All reads succeeded — commit to slot and advance ring buffer + if (wasAtCapacity) { + delete[] self->overflow_[slot].bitmap; + } else { + self->overflowCount_++; + } + self->overflowNext_ = (slot + 1) % OVERFLOW_CAPACITY; + self->overflow_[slot].glyph = tempGlyph; + self->overflow_[slot].bitmap = tempBitmap; + self->overflow_[slot].codepoint = codepoint; + self->overflow_[slot].styleIdx = styleIdx; + + LOG_DBG("SDCF", "Overflow: loaded U+%04X style %u on demand (slot %u/%u)", codepoint, styleIdx, slot, + OVERFLOW_CAPACITY); + + return &self->overflow_[slot].glyph; +} + +bool SdCardFont::isOverflowGlyph(const EpdGlyph* glyph) const { + for (uint32_t i = 0; i < overflowCount_; i++) { + if (&overflow_[i].glyph == glyph) return true; + } + return false; +} + +const uint8_t* SdCardFont::getOverflowBitmap(const EpdGlyph* glyph) const { + for (uint32_t i = 0; i < overflowCount_; i++) { + if (&overflow_[i].glyph == glyph) { + return overflow_[i].bitmap; + } + } + return nullptr; +} + +SdCardFont* SdCardFont::fromMissCtx(void* ctx) { return static_cast(ctx)->self; } diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h new file mode 100644 index 00000000..821697ee --- /dev/null +++ b/lib/EpdFont/SdCardFont.h @@ -0,0 +1,241 @@ +#pragma once + +#include + +#include "EpdFont.h" +#include "EpdFontData.h" + +// On-disk binary format version for .cpfont files. Defined as a preprocessor +// macro (rather than a constexpr) so it can be stringified into the SD-fonts +// release URL — see FONT_MANIFEST_URL in FontDownloadActivity.h. No integer +// suffix because stringification would include it (e.g. `4U` → `"4U"`). +// +// The canonical version for the build tooling lives in +// lib/EpdFont/scripts/cpfont_version.py. This firmware-side copy must be +// bumped manually when the firmware is updated to support a new format. +// Reader enforcement: SdCardFont::load(). +#define CPFONT_VERSION 4 + +class SdCardFont { + public: + static constexpr uint16_t MAX_PAGE_GLYPHS = 512; + static constexpr uint8_t MAX_STYLES = 4; + + SdCardFont() = default; + ~SdCardFont(); + // Owns raw buffers freed in dtor — no shallow-copy semantics. Make any + // accidental pass-by-value or move a compile-time error. + SdCardFont(const SdCardFont&) = delete; + SdCardFont& operator=(const SdCardFont&) = delete; + SdCardFont(SdCardFont&&) = delete; + SdCardFont& operator=(SdCardFont&&) = delete; + + // Load .cpfont file: reads header + intervals into RAM, records file layout offsets. + // Supports v4 (multi-style) format. + // Returns true on success. + bool load(const char* path); + + // Pre-read glyphs needed for the given UTF-8 text from SD card. + // styleMask: bitmask of styles to prewarm (bit 0=regular, 1=bold, 2=italic, 3=bolditalic). + // Default 0x0F = all present styles. + // When metadataOnly=true, only glyph metrics are loaded (no bitmap data). + // Returns number of glyphs that couldn't be loaded (0 on full success). + int prewarm(const char* utf8Text, uint8_t styleMask = 0x0F, bool metadataOnly = false); + + // Build a compact advance-only table for layout measurement. + // Extracts ALL unique codepoints from utf8Text (no MAX_PAGE_GLYPHS cap), + // batch-reads advanceX from SD, stores in a sorted per-style table. + // Returns number of codepoints not found in font coverage. + int buildAdvanceTable(const char* utf8Text, uint8_t styleMask = 0x0F); + + // Look up advanceX for a codepoint from the advance table. + // Returns the 12.4 fixed-point advance, or 0 if not found. + uint16_t getAdvance(uint32_t codepoint, uint8_t style) const; + + // Returns true if advance table is populated for at least one style. + bool hasAdvanceTable() const; + + // Free mini data for all styles, restore stub EpdFontData. + // Also clears the temporary advance table (built per layout pass) but + // preserves the persistent advance cache (reused across passes). + void clearCache(); + + // Drop the persistent advance cache. Call when unloading the SD font or + // when font/size/family/glyph-table state changes. + void clearPersistentCache(); + + // Returns pointer to the managed EpdFont for a given style. + // Returns nullptr if the style is not present. + EpdFont* getEpdFont(uint8_t style = 0); + + // Returns true if the given style is present in this font file. + bool hasStyle(uint8_t style) const; + + // Number of styles present in this font file. + uint8_t styleCount() const { return styleCount_; } + + // Returns true if the glyph pointer points into the overflow buffer. + bool isOverflowGlyph(const EpdGlyph* glyph) const; + + // Returns the bitmap for an on-demand-loaded (overflow) glyph. + const uint8_t* getOverflowBitmap(const EpdGlyph* glyph) const; + + // Extract SdCardFont* from an opaque glyphMissCtx pointer. + // Used by GfxRenderer::getGlyphBitmap() to recover the SdCardFont from EpdFontData::glyphMissCtx. + static SdCardFont* fromMissCtx(void* ctx); + + struct Stats { + uint32_t prewarmTotalMs = 0; + uint32_t sdReadTimeMs = 0; + uint32_t seekCount = 0; + uint32_t uniqueGlyphs = 0; + uint32_t bitmapBytes = 0; + }; + void logStats(const char* label = "SDCF"); + void resetStats(); + const Stats& getStats() const { return stats_; } + + // Content hash of the file header + style TOC entries (computed during load). + // Used to generate deterministic font IDs for section cache invalidation. + uint32_t contentHash() const { return contentHash_; } + + private: + // Per-style metadata (parsed from file header/TOC) + struct CpFontHeader { + uint32_t intervalCount = 0; + uint32_t glyphCount = 0; + uint8_t advanceY = 0; + int16_t ascender = 0; + int16_t descender = 0; + bool is2Bit = false; + uint16_t kernLeftEntryCount = 0; + uint16_t kernRightEntryCount = 0; + uint8_t kernLeftClassCount = 0; + uint8_t kernRightClassCount = 0; + uint8_t ligaturePairCount = 0; + }; + + // All per-style data: file offsets, intervals, kern/lig, prewarm cache, EpdFont + struct PerStyle { + CpFontHeader header{}; + + // File layout offsets for this style's data sections + uint32_t intervalsFileOffset = 0; + uint32_t glyphsFileOffset = 0; + uint32_t kernLeftFileOffset = 0; + uint32_t kernRightFileOffset = 0; + uint32_t kernMatrixFileOffset = 0; + uint32_t ligatureFileOffset = 0; + uint32_t bitmapFileOffset = 0; + + // Full intervals loaded from file (kept in RAM for codepoint lookup) + EpdUnicodeInterval* fullIntervals = nullptr; + + // Persistent kern-class + ligature tables (lazy-loaded on first prewarm). + // The full kern MATRIX is NOT resident — on Literata-class fonts a single + // style's matrix is ~36-42KB contiguous, and 4 styles' worth won't fit + // alongside bitmaps + framebuffer on a 380KB device. Only kernLeftClasses + // and kernRightClasses (small codepoint→classId tables, ~3KB each) stay + // resident; the matrix is reconstructed per-page as miniKernMatrix. + EpdKernClassEntry* kernLeftClasses = nullptr; + EpdKernClassEntry* kernRightClasses = nullptr; + EpdLigaturePair* ligaturePairs = nullptr; + bool kernLigLoaded = false; + + // Stub EpdFontData returned when not prewarmed + EpdFontData stubData{}; + + // Mini EpdFontData built during prewarm + EpdFontData miniData{}; + EpdUnicodeInterval* miniIntervals = nullptr; + EpdGlyph* miniGlyphs = nullptr; + uint8_t* miniBitmap = nullptr; + uint32_t miniIntervalCount = 0; + uint32_t miniGlyphCount = 0; + + // Per-page mini kern matrix (built by buildMiniKernMatrix on each full + // prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints + // used on the current page to renumbered class IDs (1..miniKern*ClassCount). + // miniKernMatrix is a small miniKernLeftClassCount × miniKernRightClassCount + // flat matrix. Typical Latin page: ~25×25 matrix = ~625 bytes per style vs + // ~36KB for the full Literata matrix — ~50× reduction. + EpdKernClassEntry* miniKernLeftClasses = nullptr; + EpdKernClassEntry* miniKernRightClasses = nullptr; + uint16_t miniKernLeftEntryCount = 0; + uint16_t miniKernRightEntryCount = 0; + uint8_t miniKernLeftClassCount = 0; + uint8_t miniKernRightClassCount = 0; + int8_t* miniKernMatrix = nullptr; + + // The EpdFont whose data pointer we manage + EpdFont epdFont{&stubData}; + + bool present = false; + }; + + PerStyle styles_[MAX_STYLES] = {}; + uint8_t styleCount_ = 0; + + char filePath_[128] = {}; + + // Overflow context: glyphMissHandler needs to know which style it's serving + struct OverflowContext { + SdCardFont* self; + uint8_t styleIdx; + }; + OverflowContext overflowCtx_[MAX_STYLES] = {}; + + // Shared on-demand overflow buffer (ring buffer of glyphs loaded via glyphMissHandler) + static constexpr uint32_t OVERFLOW_CAPACITY = 8; + struct OverflowEntry { + EpdGlyph glyph; + uint8_t* bitmap = nullptr; + uint32_t codepoint = 0; + uint8_t styleIdx = 0; + }; + OverflowEntry overflow_[OVERFLOW_CAPACITY] = {}; + uint32_t overflowCount_ = 0; + uint32_t overflowNext_ = 0; + + // Compact advance-only table for layout measurement (per-style). + // Built by buildAdvanceTable(), queried by getAdvance(). + struct AdvanceEntry { + uint32_t codepoint; + uint16_t advanceX; // 12.4 fixed-point + }; + // Per-style advance table. Sorted by codepoint for binary lookup. + // Bounded to ADVANCE_CACHE_LIMIT entries; persists across layout passes + // (across calls to clearCache()) so repeated indexing of the same font + // amortizes SD reads. Cleared only on font unload or clearPersistentCache(). + static constexpr uint32_t ADVANCE_CACHE_LIMIT = 768; + AdvanceEntry* advanceTable_[MAX_STYLES] = {}; + uint32_t advanceTableSize_[MAX_STYLES] = {}; + bool advanceTableLookup(uint8_t styleIdx, uint32_t codepoint, uint16_t* outAdvance) const; + // Merge sortedNew (sorted by codepoint, no overlap with existing) into the + // advance table for styleIdx, preserving sort order; cap-truncates the tail. + void mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sortedNew, uint32_t newCount); + + Stats stats_; + uint32_t contentHash_ = 0; + bool loaded_ = false; + + // Per-style helpers + void freeStyleMiniData(PerStyle& s); + void freeStyleAll(PerStyle& s); + void freeStyleKernLigatureData(PerStyle& s); + void freeStyleMiniKern(PerStyle& s); + bool loadStyleKernLigatureData(PerStyle& s); + bool buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount); + void applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const; + void applyGlyphMissCallback(uint8_t styleIdx); + int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const; + int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly); + + // Global helpers + void freeAll(); + void clearOverflow(); + static void computeStyleFileOffsets(PerStyle& s, uint32_t baseOffset); + + // Static callback for EpdFontData::glyphMissHandler (per-style via OverflowContext) + static const EpdGlyph* onGlyphMiss(void* ctx, uint32_t codepoint); +}; diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp new file mode 100644 index 00000000..a6032336 --- /dev/null +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -0,0 +1,98 @@ +#include "SdCardFontManager.h" + +#include +#include +#include +#include +#include + +SdCardFontManager::~SdCardFontManager() { + for (auto& lf : loaded_) { + delete lf.font; + } +} + +// FNV-1a continuation: seeds with contentHash, then hashes family name + point size. +// Produces a deterministic ID that is stable across load/unload cycles and reboots, +// and changes when font content changes (different header/TOC = different contentHash). +int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize) { + static constexpr uint32_t FNV_PRIME = 16777619u; + uint32_t hash = contentHash; + while (*familyName) { + hash ^= static_cast(*familyName++); + hash *= FNV_PRIME; + } + hash ^= pointSize; + hash *= FNV_PRIME; + int id = static_cast(hash); + return id != 0 ? id : 1; // 0 is reserved as "not found" sentinel +} + +bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum) { + // Unload any previously loaded family first + if (!loadedFamilyName_.empty()) { + unloadAll(renderer); + } + + // Select by ordinal position: sort available sizes, then map the font size + // enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the + // family has fewer sizes than 4, clamp to the last available size. + auto sizes = family.availableSizes(); + if (sizes.empty()) { + LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str()); + return false; + } + + uint8_t idx = fontSizeEnum; + if (idx >= sizes.size()) idx = sizes.size() - 1; + const SdCardFontFileInfo* selected = family.findFile(sizes[idx]); + + auto* font = new (std::nothrow) SdCardFont(); + if (!font) { + LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str()); + return false; + } + + if (!font->load(selected->path.c_str())) { + LOG_ERR("SDMGR", "Failed to load %s", selected->path.c_str()); + delete font; + return false; + } + + int fontId = computeFontId(font->contentHash(), family.name.c_str(), selected->pointSize); + // Guard against collision with built-in font IDs (astronomically unlikely + // with FNV-1a hashes, but provides a safety net) + if (renderer.getFontMap().count(fontId) != 0) { + LOG_ERR("SDMGR", "Font ID %d collides with existing font, skipping %s", fontId, selected->path.c_str()); + delete font; + return false; + } + renderer.registerSdCardFont(fontId, font); + loaded_.push_back({font, fontId, selected->pointSize}); + + LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (sizeEnum=%u)", selected->path.c_str(), selected->pointSize, + fontId, font->styleCount(), fontSizeEnum); + + EpdFontFamily fontFamily(font->getEpdFont(0), font->getEpdFont(1), font->getEpdFont(2), font->getEpdFont(3)); + renderer.insertFont(fontId, fontFamily); + + loadedFamilyName_ = family.name; + loadedPointSize_ = selected->pointSize; + return true; +} + +void SdCardFontManager::unloadAll(GfxRenderer& renderer) { + renderer.clearSdCardFonts(); + for (auto& lf : loaded_) { + renderer.removeFont(lf.fontId); + delete lf.font; + } + loaded_.clear(); + loadedFamilyName_.clear(); + loadedPointSize_ = 0; +} + +int SdCardFontManager::getFontId(const std::string& familyName) const { + if (familyName != loadedFamilyName_ || loaded_.empty()) return 0; + return loaded_.front().fontId; +} diff --git a/lib/EpdFont/SdCardFontManager.h b/lib/EpdFont/SdCardFontManager.h new file mode 100644 index 00000000..aec07472 --- /dev/null +++ b/lib/EpdFont/SdCardFontManager.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +class GfxRenderer; +class SdCardFont; +struct SdCardFontFamilyInfo; + +class SdCardFontManager { + public: + SdCardFontManager() = default; + ~SdCardFontManager(); + SdCardFontManager(const SdCardFontManager&) = delete; + SdCardFontManager& operator=(const SdCardFontManager&) = delete; + + // Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by + // ordinal position in the family's sorted size list. Only one .cpfont file + // is loaded; other sizes remain on disk. This keeps resident interval + + // kern/ligature tables to one size's worth of memory. + // Returns true on success. + bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum); + + // Unload everything, unregister from renderer. + void unloadAll(GfxRenderer& renderer); + + // Look up the font ID for the loaded family. Returns 0 if nothing loaded + // or familyName doesn't match. + int getFontId(const std::string& familyName) const; + + // Get name of currently loaded family (empty if none). + const std::string& currentFamilyName() const { return loadedFamilyName_; }; + + // Point size that was actually loaded (closest match to targetPtSize). + // 0 if nothing loaded. + uint8_t currentPointSize() const { return loadedPointSize_; }; + + private: + struct LoadedFont { + SdCardFont* font; // heap-allocated, owned + int fontId; + uint8_t size; + }; + static int computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize); + + std::string loadedFamilyName_; + uint8_t loadedPointSize_ = 0; + std::vector loaded_; +}; diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp new file mode 100644 index 00000000..2e0f145c --- /dev/null +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -0,0 +1,230 @@ +#include "SdCardFontRegistry.h" + +#include +#include + +#include +#include + +// --- SdCardFontFamilyInfo helpers --- + +const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t style) const { + for (const auto& f : files) { + if (f.pointSize == size && f.style == style) return &f; + } + return nullptr; +} + +bool SdCardFontFamilyInfo::hasSize(uint8_t size) const { + for (const auto& f : files) { + if (f.pointSize == size) return true; + } + return false; +} + +std::vector SdCardFontFamilyInfo::availableSizes() const { + std::vector sizes; + for (const auto& f : files) { + bool found = false; + for (uint8_t s : sizes) { + if (s == f.pointSize) { + found = true; + break; + } + } + if (!found) sizes.push_back(f.pointSize); + } + std::sort(sizes.begin(), sizes.end()); + return sizes; +} + +// --- SdCardFontRegistry --- + +bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) { + // V4 naming: _.cpfont (e.g. Bookerly-SD_14.cpfont) + // Use an ends-with check rather than strstr() so that in-progress downloads + // like "Foo_14.cpfont.tmp" or backups like "Foo_14.cpfont~" aren't accepted. + static constexpr char kExt[] = ".cpfont"; + static constexpr size_t kExtLen = sizeof(kExt) - 1; + const size_t nameLen = strlen(filename); + if (nameLen <= kExtLen) return false; + if (strcmp(filename + nameLen - kExtLen, kExt) != 0) return false; + const char* ext = filename + nameLen - kExtLen; + + size_t baseLen = ext - filename; + if (baseLen == 0 || baseLen > 127) return false; + + char base[128]; + memcpy(base, filename, baseLen); + base[baseLen] = '\0'; + + char* lastUnderscore = strrchr(base, '_'); + if (!lastUnderscore || lastUnderscore == base) return false; + + const char* sizeStr = lastUnderscore + 1; + char* endPtr; + long sizeVal = strtol(sizeStr, &endPtr, 10); + if (endPtr == sizeStr || *endPtr != '\0' || sizeVal < 1 || sizeVal > 255) return false; + size = static_cast(sizeVal); + // V4 .cpfont files bundle every style (regular/bold/italic/bold-italic) into + // one file, so style is always 0 at the registry level. The per-style + // bitstream is selected later by SdCardFont::getEpdFont(style). The `style` + // field in SdCardFontFileInfo is reserved for future formats that split + // styles across files; scanDirectory() defends against accidental + // (pointSize, style) collisions in that scenario. + style = 0; + return true; +} + +void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family) { + FsFile dir = Storage.open(dirPath); + if (!dir || !dir.isDirectory()) return; + + char nameBuffer[128]; + while (true) { + FsFile entry = dir.openNextFile(); + if (!entry) break; + if (entry.isDirectory()) { + entry.close(); + continue; + } + + entry.getName(nameBuffer, sizeof(nameBuffer)); + entry.close(); + + // Skip macOS resource fork files (._*) and other hidden files + if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; + + uint8_t size, style; + if (!parseFilename(nameBuffer, size, style)) continue; + + // Reject duplicate (pointSize, style) entries in the same family. With + // v4's bundle-everything design parseFilename always returns style=0, so + // two files at the same size in the same family would silently shadow + // each other in findFile(). Skip the duplicate and warn. + bool duplicate = false; + for (const auto& existing : family.files) { + if (existing.pointSize == size && existing.style == style) { + duplicate = true; + break; + } + } + if (duplicate) { + LOG_ERR("SDREG", "Duplicate font %s in %s — skipping", nameBuffer, dirPath); + continue; + } + + SdCardFontFileInfo info; + info.path = std::string(dirPath) + "/" + nameBuffer; + info.pointSize = size; + info.style = style; + family.files.push_back(std::move(info)); + } +} + +// Scan a single root (e.g. "/.fonts") and append its families to `out`. +// Skips families whose names already exist in `out` (de-duplicates between +// the hidden and visible roots — first scan wins). +void SdCardFontRegistry::scanRoot(const char* rootPath, std::vector& out) { + FsFile root = Storage.open(rootPath); + if (!root) { + LOG_DBG("SDREG", "Fonts directory not found: %s", rootPath); + return; + } + if (!root.isDirectory()) { + LOG_ERR("SDREG", "Fonts path is not a directory: %s", rootPath); + return; + } + + char nameBuffer[128]; + while (true) { + FsFile entry = root.openNextFile(); + if (!entry) break; + if (entry.isDirectory()) { + entry.getName(nameBuffer, sizeof(nameBuffer)); + entry.close(); + + // Skip hidden/system directories inside the root (macOS ._*, .Trashes, etc.) + if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; + + // De-dup by family name across roots. + bool exists = false; + for (const auto& fam : out) { + if (fam.name == nameBuffer) { + exists = true; + break; + } + } + if (exists) continue; + + SdCardFontFamilyInfo family; + family.name = nameBuffer; + std::string subDirPath = std::string(rootPath) + "/" + nameBuffer; + SdCardFontRegistry::scanDirectory(subDirPath.c_str(), family); + + if (!family.files.empty()) { + out.push_back(std::move(family)); + LOG_DBG("SDREG", "Found family: %s (%d files) in %s", out.back().name.c_str(), + static_cast(out.back().files.size()), rootPath); + } + } else { + entry.close(); + } + } +} + +bool SdCardFontRegistry::discover() { + families_.clear(); + families_.reserve(MAX_SD_FAMILIES); + + // Hidden root is scanned first so it wins on name collisions, matching the + // sleep-folder pattern (/.sleep preferred over /sleep). + scanRoot(FONTS_DIR_HIDDEN, families_); + scanRoot(FONTS_DIR_VISIBLE, families_); + + // Sort families alphabetically + std::sort(families_.begin(), families_.end(), + [](const SdCardFontFamilyInfo& a, const SdCardFontFamilyInfo& b) { return a.name < b.name; }); + + // Cap at MAX_SD_FAMILIES + if (static_cast(families_.size()) > MAX_SD_FAMILIES) { + families_.resize(MAX_SD_FAMILIES); + } + + LOG_DBG("SDREG", "Discovery complete: %d families", static_cast(families_.size())); + return !families_.empty(); +} + +const char* SdCardFontRegistry::findFamilyRoot(const char* familyName) { + if (!familyName || !*familyName) return nullptr; + char path[160]; + snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_HIDDEN, familyName); + if (Storage.exists(path)) return FONTS_DIR_HIDDEN; + snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_VISIBLE, familyName); + if (Storage.exists(path)) return FONTS_DIR_VISIBLE; + return nullptr; +} + +const char* SdCardFontRegistry::defaultWriteRoot() { + // If exactly one of the roots already exists, keep using it. Otherwise + // (neither exists, or both exist) prefer the hidden root for new installs. + bool hiddenExists = Storage.exists(FONTS_DIR_HIDDEN); + bool visibleExists = Storage.exists(FONTS_DIR_VISIBLE); + if (hiddenExists) return FONTS_DIR_HIDDEN; + if (visibleExists) return FONTS_DIR_VISIBLE; + return FONTS_DIR_HIDDEN; +} + +const SdCardFontFamilyInfo* SdCardFontRegistry::findFamily(const std::string& name) const { + for (const auto& f : families_) { + if (f.name == name) return &f; + } + return nullptr; +} + +int SdCardFontRegistry::getFamilyIndex(const std::string& name) const { + for (int i = 0; i < static_cast(families_.size()); i++) { + if (families_[i].name == name) return i; + } + return -1; +} diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h new file mode 100644 index 00000000..f96035ed --- /dev/null +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +struct SdCardFontFileInfo { + std::string path; // v4 on-disk naming: "///_.cpfont" + // where is "/.fonts" (preferred, hidden) or "/fonts" (visible). + // e.g. "/.fonts/NotoSansCJK/NotoSansCJK_14.cpfont" + uint8_t pointSize; // parsed from filename: 14 + uint8_t style; // always 0 in v4 (all 4 styles bundled in one file); + // kept for potential future formats +}; + +struct SdCardFontFamilyInfo { + std::string name; // directory name, e.g. "NotoSansCJK" + std::vector files; + + const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; + bool hasSize(uint8_t size) const; + std::vector availableSizes() const; +}; + +class SdCardFontRegistry { + public: + static constexpr int MAX_SD_FAMILIES = 128; + // Two top-level roots are scanned at discovery time. Hidden is preferred + // when creating new installs; both are read from if present. + static constexpr const char* FONTS_DIR_HIDDEN = "/.fonts"; + static constexpr const char* FONTS_DIR_VISIBLE = "/fonts"; + + // Returns the existing root for `familyName` (the one that contains + // ///), or nullptr if the family is not installed in + // either root. Used by writers to keep re-installs in their existing dir. + static const char* findFamilyRoot(const char* familyName); + + // Returns the root path that should be used when creating a brand-new + // family on disk (no prior install): the existing root if exactly one of + // the two roots exists, otherwise the hidden root. + static const char* defaultWriteRoot(); + + // Scan SD card, populate families_. Returns true if any families found. + bool discover(); + + const std::vector& getFamilies() const { return families_; } + const SdCardFontFamilyInfo* findFamily(const std::string& name) const; + int getFamilyIndex(const std::string& name) const; + int getFamilyCount() const { return static_cast(families_.size()); } + + private: + std::vector families_; // sorted alphabetically + + static bool parseFilename(const char* filename, uint8_t& size, uint8_t& style); + static void scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family); + // Scan one root (e.g. "/.fonts"), append families to `out`, dedup by name. + static void scanRoot(const char* rootPath, std::vector& out); +}; diff --git a/lib/EpdFont/builtinFonts/source/.gitignore b/lib/EpdFont/builtinFonts/source/.gitignore new file mode 100644 index 00000000..1c3f6a00 --- /dev/null +++ b/lib/EpdFont/builtinFonts/source/.gitignore @@ -0,0 +1,12 @@ +# Ignore all font directories except those committed to the repo. +# Fonts like NotoSansCJK are downloaded on demand by build-sd-fonts.py. +* +!.gitignore +!NotoSerif/ +!NotoSerif/** +!NotoSans/ +!NotoSans/** +!OpenDyslexic/ +!OpenDyslexic/** +!Ubuntu/ +!Ubuntu/** diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py new file mode 100755 index 00000000..50bc67a8 --- /dev/null +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Build SD card fonts from a declarative YAML config. + +Reads sd-fonts.yaml, downloads any missing source fonts, runs +fontconvert_sdcard.py in parallel for each family, and optionally +generates the fonts.json manifest. + +Usage: + # Generate fonts (output in ./output/) + python3 build-sd-fonts.py + + # Generate fonts + manifest + python3 build-sd-fonts.py --manifest --base-url "http://localhost:8000/" + + # Custom config / output paths + python3 build-sd-fonts.py --config my-fonts.yaml --output-dir dist/ + + # Generate only specific families + python3 build-sd-fonts.py --only Literata,IBMPlexMono +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.request +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +import yaml + +SCRIPT_DIR = Path(__file__).parent +FONTCONVERT = SCRIPT_DIR / "fontconvert_sdcard.py" +EPDFONTS_DIR = SCRIPT_DIR.parent # lib/EpdFont +DEFAULT_CONFIG = SCRIPT_DIR / "sd-fonts.yaml" +DEFAULT_OUTPUT = SCRIPT_DIR / "output" +DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts" +INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts" + + +def download_font(url: str, dest: Path) -> Path: + """Download a font file if not already cached. Returns the local path.""" + if dest.exists(): + return dest + dest.parent.mkdir(parents=True, exist_ok=True) + print(f" Downloading {dest.name}...") + try: + urllib.request.urlretrieve(url, dest) + except Exception as e: + dest.unlink(missing_ok=True) + raise RuntimeError(f"Failed to download {url}: {e}") from e + size_kb = dest.stat().st_size / 1024 + print(f" Downloaded {dest.name} ({size_kb:.0f} KB)") + return dest + + +def extract_static_instance(source_path: Path, axes: dict, family_name: str, style_name: str) -> Path: + """Use fonttools instancer to pin variable font axes, producing a static TTF. + + Caches the result in INSTANCE_DIR// + + +

📚 CrossPoint Reader

+ + + +
+

Installed Fonts

+

Loading...

+
+ +
+

Upload Font

+
+ + +
+

+
+
+ + + + diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index e073e6d8..a67ddbd1 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -104,6 +104,7 @@ Home File Manager Settings + Fonts
diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index 47d846f2..0a73f9bd 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -285,6 +285,7 @@ Home File Manager Settings + Fonts