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 <zach@zdnelson.com>
Co-authored-by: Justin <itsthisjustin@users.noreply.github.com>
Co-authored-by: jpirnay <jens@pirnay.com>
Co-authored-by: mcrosson <kemonine@kemonine.info>
This commit is contained in:
Adrian Wilkins-Caruana
2026-05-08 21:50:06 -05:00
committed by Zach Nelson
co-authored by Zach Nelson Justin jpirnay mcrosson
parent 29fd29f537
commit 7993b2bb97
57 changed files with 6064 additions and 54 deletions
+106
View File
@@ -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."
+4
View File
@@ -15,3 +15,7 @@ build
.history/
/.venv
*.local*
*.cpfont
lib/EpdFont/scripts/downloaded_fonts/
lib/EpdFont/scripts/instanced_fonts/
lib/EpdFont/scripts/output/
+94
View File
@@ -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.
+20 -12
View File
@@ -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);
}
+12
View File
@@ -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;
+37
View File
@@ -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<uint32_t>(leftIdx)) hasLeft = true;
if (neededGlyphs[i] == static_cast<uint32_t>(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<uint32_t>(outIdx)) {
found = true;
break;
}
}
if (!found) {
neededGlyphs[glyphCount++] = static_cast<uint32_t>(outIdx);
}
}
}
if (glyphCount == 0) return 0;
// Step 2: Compute total buffer size and collect unique groups
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
#pragma once
#include <cstdint>
#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);
};
+98
View File
@@ -0,0 +1,98 @@
#include "SdCardFontManager.h"
#include <EpdFontFamily.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <SdCardFontRegistry.h>
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<uint8_t>(*familyName++);
hash *= FNV_PRIME;
}
hash ^= pointSize;
hash *= FNV_PRIME;
int id = static_cast<int>(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;
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
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<LoadedFont> loaded_;
};
+230
View File
@@ -0,0 +1,230 @@
#include "SdCardFontRegistry.h"
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
#include <cstring>
// --- 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<uint8_t> SdCardFontFamilyInfo::availableSizes() const {
std::vector<uint8_t> 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: <name>_<size>.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<uint8_t>(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<SdCardFontFamilyInfo>& 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<int>(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<int>(families_.size()) > MAX_SD_FAMILIES) {
families_.resize(MAX_SD_FAMILIES);
}
LOG_DBG("SDREG", "Discovery complete: %d families", static_cast<int>(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<int>(families_.size()); i++) {
if (families_[i].name == name) return i;
}
return -1;
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
struct SdCardFontFileInfo {
std::string path; // v4 on-disk naming: "/<root>/<Family>/<Family>_<size>.cpfont"
// where <root> 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<SdCardFontFileInfo> files;
const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
bool hasSize(uint8_t size) const;
std::vector<uint8_t> 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
// /<root>/<familyName>/), 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<SdCardFontFamilyInfo>& 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<int>(families_.size()); }
private:
std::vector<SdCardFontFamilyInfo> 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<SdCardFontFamilyInfo>& out);
};
@@ -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/**
+331
View File
@@ -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/<family>/<style>_<axes>_<mtime>.ttf.
Returns the path to the static font file.
"""
from fontTools.varLib.instancer import instantiateVariableFont
from fontTools.ttLib import TTFont
mtime = int(source_path.stat().st_mtime)
axis_key = "_".join(f"{k}{v}" for k, v in sorted(axes.items()))
cache_name = f"{style_name}_{axis_key}_{mtime}.ttf"
cached = INSTANCE_DIR / family_name / cache_name
if cached.exists():
return cached
# Clean old cached instances for this style
cached.parent.mkdir(parents=True, exist_ok=True)
for old in cached.parent.glob(f"{style_name}_*.ttf"):
old.unlink()
print(f" Extracting static instance: {family_name}/{style_name} ({axis_key})")
# Atomic write: save to a temp file first, then rename. A crash or save()
# exception would otherwise leave a corrupt `cached` file that future runs
# would happily reuse via the `cached.exists()` check above.
tmp_fd, tmp_name = tempfile.mkstemp(suffix=".ttf", dir=cached.parent)
os.close(tmp_fd)
tmp_path = Path(tmp_name)
font = TTFont(str(source_path))
try:
instantiateVariableFont(font, axes)
font.save(str(tmp_path))
except Exception:
tmp_path.unlink(missing_ok=True)
raise
finally:
font.close()
tmp_path.replace(cached)
return cached
def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Path:
"""Resolve a style spec (path or url) to a local font file path.
If 'variable' key is present, extracts a static instance via fonttools
instancer after resolving the source file.
"""
if "path" in style_spec:
resolved = EPDFONTS_DIR / style_spec["path"]
if not resolved.exists():
raise FileNotFoundError(f"{family_name}/{style_name}: {resolved} not found")
elif "url" in style_spec:
url = style_spec["url"]
# Derive a stable filename from the URL
filename = url.rsplit("/", 1)[-1]
dest = DOWNLOAD_DIR / family_name / filename
resolved = download_font(url, dest)
else:
raise ValueError(f"{family_name}/{style_name}: must have 'path' or 'url'")
# If variable font axes are specified, extract a static instance
if "variable" in style_spec:
resolved = extract_static_instance(
resolved, style_spec["variable"], family_name, style_name
)
return resolved
def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]:
"""Build a single font family. Returns (name, success, message)."""
name = family["name"]
output_dir = output_base / name
output_dir.mkdir(parents=True, exist_ok=True)
styles = family.get("styles", {})
intervals = family["intervals"]
sizes = ",".join(str(s) for s in family["sizes"])
# Resolve all font file paths (downloads as needed)
try:
resolved_styles = {}
for style_name, style_spec in styles.items():
resolved_styles[style_name] = resolve_font_path(style_spec, name, style_name)
except (FileNotFoundError, RuntimeError) as e:
return name, False, str(e)
# Build the fontconvert_sdcard.py command
cmd = [sys.executable, str(FONTCONVERT)]
multi_style = len(resolved_styles) > 1 or "regular" not in resolved_styles
has_any_multi = any(k in resolved_styles for k in ("regular", "bold", "italic", "bolditalic"))
if has_any_multi and len(resolved_styles) > 1:
# Multi-style mode
for style_name, font_path in resolved_styles.items():
cmd.extend([f"--{style_name}", str(font_path)])
else:
# Single-style mode
style_name = next(iter(resolved_styles))
font_path = resolved_styles[style_name]
cmd.append(str(font_path))
cmd.extend(["--style", style_name])
cmd.extend(["--intervals", intervals])
cmd.extend(["--sizes", sizes])
cmd.extend(["--name", name])
cmd.extend(["--output-dir", str(output_dir) + "/"])
if family.get("force_autohint", False):
cmd.append("--force-autohint")
# Run fontconvert_sdcard.py
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600,
)
if result.returncode != 0:
return name, False, result.stderr.strip() or f"Exit code {result.returncode}"
return name, True, ""
except subprocess.TimeoutExpired:
return name, False, "Timed out after 600s"
except Exception as e:
return name, False, str(e)
def generate_manifest(
config_path: Path, output_base: Path, base_url: str, manifest_path: Path
):
"""Generate fonts.json manifest from config + built output.
Uses the standalone generate-font-manifest.py as a subprocess so
descriptions come from the YAML config via --descriptions-from.
"""
manifest_script = SCRIPT_DIR.parent.parent.parent / "scripts" / "generate-font-manifest.py"
if not base_url.endswith("/"):
base_url += "/"
cmd = [
sys.executable, str(manifest_script),
"--input", str(output_base),
"--base-url", base_url,
"--output", str(manifest_path),
]
if config_path.exists():
cmd.extend(["--descriptions-from", str(config_path)])
manifest_path.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"ERROR: Manifest generation failed:\n{result.stderr}", file=sys.stderr)
return
print(result.stdout, end="")
print(f"Manifest written: {manifest_path}")
def main():
parser = argparse.ArgumentParser(description="Build SD card fonts from YAML config")
parser.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Path to font families YAML config"
)
parser.add_argument(
"--output-dir", default=str(DEFAULT_OUTPUT), help="Output directory for .cpfont files"
)
parser.add_argument("--only", help="Comma-separated family names to build (default: all)")
parser.add_argument("--manifest", action="store_true", help="Also generate fonts.json manifest")
parser.add_argument("--base-url", default="", help="Base URL for manifest (required with --manifest)")
parser.add_argument(
"--manifest-output", default=None, help="Manifest output path (default: <output-dir>/fonts.json)"
)
parser.add_argument(
"--jobs", "-j", type=int, default=None,
help="Max parallel jobs (default: number of families)"
)
parser.add_argument("--clean", action="store_true", help="Clean output directory before building")
args = parser.parse_args()
if args.manifest and not args.base_url:
parser.error("--base-url is required when using --manifest")
# Load config
config_path = Path(args.config)
if not config_path.exists():
print(f"ERROR: Config not found: {config_path}", file=sys.stderr)
sys.exit(1)
with open(config_path) as f:
config = yaml.safe_load(f)
families = config.get("families", [])
if not families:
print("ERROR: No families defined in config", file=sys.stderr)
sys.exit(1)
# Filter if --only specified
if args.only:
only_names = set(args.only.split(","))
families = [f for f in families if f["name"] in only_names]
missing = only_names - {f["name"] for f in families}
if missing:
print(f"WARNING: families not found in config: {', '.join(missing)}", file=sys.stderr)
if not families:
print("ERROR: no matching families after --only filter", file=sys.stderr)
sys.exit(1)
output_base = Path(args.output_dir)
if args.clean and output_base.exists():
print(f"Cleaning {output_base}...")
shutil.rmtree(output_base)
output_base.mkdir(parents=True, exist_ok=True)
# Download phase (sequential — avoids hammering servers)
print(f"\n=== Resolving {len(families)} font families ===\n")
for family in families:
for style_name, style_spec in family.get("styles", {}).items():
if "url" in style_spec:
try:
resolve_font_path(style_spec, family["name"], style_name)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# Build phase (parallel)
max_workers = args.jobs or len(families)
print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs) ===\n")
failed = []
with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(build_family, family, output_base): family["name"]
for family in families
}
for future in as_completed(futures):
name, success, message = future.result()
if success:
# Count output files
family_dir = output_base / name
count = len(list(family_dir.glob("*.cpfont")))
size = sum(f.stat().st_size for f in family_dir.glob("*.cpfont"))
print(f" OK: {name} ({count} files, {size / 1024 / 1024:.1f} MB)")
else:
print(f" FAILED: {name}: {message}", file=sys.stderr)
failed.append(name)
# Summary
print("\n=== Summary ===\n")
total_files = len(list(output_base.rglob("*.cpfont")))
total_size = sum(f.stat().st_size for f in output_base.rglob("*.cpfont"))
print(f"Total: {total_files} .cpfont files ({total_size / 1024 / 1024:.1f} MB)")
if failed:
print(f"\nFailed families: {', '.join(failed)}", file=sys.stderr)
# Manifest
if args.manifest:
manifest_path = Path(args.manifest_output) if args.manifest_output else output_base / "fonts.json"
generate_manifest(config_path, output_base, args.base_url, manifest_path)
if failed:
sys.exit(1)
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
# Canonical version constants for the .cpfont binary format and font manifest.
#
# These are the single source of truth for the build tooling. The CI workflow
# (release-fonts.yml) and both Python scripts (fontconvert_sdcard.py,
# generate-font-manifest.py) read from here.
#
# The firmware C++ headers (SdCardFont.h, FontDownloadActivity.h) carry their
# own copies — those must be bumped manually when the firmware is updated to
# support a new version.
# .cpfont binary format version. Bump when the on-disk struct layout changes.
CPFONT_VERSION = 4
# JSON manifest schema version. Bump when the manifest shape changes.
FONTS_MANIFEST_VERSION = 1
+898
View File
@@ -0,0 +1,898 @@
#!/usr/bin/env python3
"""Generate .cpfont binary files for SD card font loading.
Outputs binary .cpfont files containing glyph metadata and uncompressed
2-bit bitmaps, matching the EpdFontData/EpdGlyph/EpdUnicodeInterval struct
layout on the ESP32-C3 (little-endian, RISC-V).
Usage:
# Single file with specific presets
python fontconvert_sdcard.py \\
--intervals latin-ext,greek,cyrillic \\
--size 14 --style regular \\
NotoSans-Regular.ttf \\
-o NotoSansExt_14.cpfont
# All 4 sizes at once
python fontconvert_sdcard.py \\
--intervals cjk \\
--sizes 12,14,16,18 --style regular \\
NotoSansCJKsc-Regular.otf \\
--output-dir NotoSansCJK/
"""
import freetype
import struct
import sys
import os
import math
import argparse
from collections import namedtuple
from fontTools.ttLib import TTFont
from cpfont_version import CPFONT_VERSION
# --- Unicode interval presets ---
INTERVAL_PRESETS = {
"ascii": [(0x0020, 0x007E)],
"latin1": [(0x0080, 0x00FF)],
"latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F),
(0x1E00, 0x1EFF), (0x2000, 0x206F)],
"greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)],
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
"georgian": [(0x10A0, 0x10FF), (0x2D00, 0x2D2F)],
"armenian": [(0x0530, 0x058F)],
"ethiopic": [(0x1200, 0x137F), (0x1380, 0x139F), (0x2D80, 0x2DDF)],
"vietnamese": [(0x01A0, 0x01B0), (0x1EA0, 0x1EF9)],
"punctuation": [(0x2000, 0x206F)],
"cjk": [(0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF),
(0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFF00, 0xFFEF)],
"hangul": [(0xAC00, 0xD7AF), (0x1100, 0x11FF), (0x3130, 0x318F)],
"cherokee": [(0x13A0, 0x13FF), (0xAB70, 0xABBF)],
"tifinagh": [(0x2D30, 0x2D7F)],
# Symbol blocks commonly seen in scifi/popsci/literary fiction.
"symbols": [(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF)],
# Composite preset for English-language literary fiction including scifi/popsci.
# Greek for physics terms, math operators, miscellaneous symbols (♪♫♬), dingbats.
"reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF),
(0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF),
(0xFB00, 0xFB06)],
# Matches the built-in font intervals from fontconvert.py exactly
"builtin": [(0x0000, 0x007F), (0x0080, 0x00FF), (0x0100, 0x017F),
(0x01A0, 0x01A1), (0x01AF, 0x01B0), (0x01C4, 0x021F),
(0x0300, 0x036F), (0x0400, 0x04FF),
(0x1EA0, 0x1EF9), (0x2000, 0x206F), (0x20A0, 0x20CF),
(0x2070, 0x209F), (0x2190, 0x21FF), (0x2200, 0x22FF),
(0xFB00, 0xFB06)],
}
def resolve_intervals(preset_str):
"""Resolve comma-separated preset names into a merged, sorted, deduplicated interval list."""
all_intervals = []
for name in preset_str.split(","):
name = name.strip().lower()
if name not in INTERVAL_PRESETS:
print(f"Error: unknown interval preset '{name}'", file=sys.stderr)
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr)
sys.exit(1)
all_intervals.extend(INTERVAL_PRESETS[name])
# Always add replacement character
all_intervals.append((0xFFFD, 0xFFFD))
# Sort and merge overlapping/adjacent intervals
all_intervals.sort()
merged = []
for start, end in all_intervals:
if merged and start <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
GlyphProps = namedtuple("GlyphProps", [
"width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"
])
# Intermediate data from rasterizing one font style
StyleRasterData = namedtuple("StyleRasterData", [
"style_id", # 0=regular, 1=bold, 2=italic, 3=bolditalic
"intervals", # validated intervals [(start, end), ...]
"all_glyphs", # [(GlyphProps, packed_bytes), ...]
"total_bitmap_size", # int
"advanceY", "ascender", "descender",
"kern_left_classes", "kern_right_classes", "kern_matrix",
"kern_left_class_count", "kern_right_class_count",
"ligature_pairs",
])
def norm_floor(val):
return int(math.floor(val / (1 << 6)))
def norm_ceil(val):
return int(math.ceil(val / (1 << 6)))
# Fixed-point (fp4) output conventions (must match EpdFontData.h / fp4 namespace):
#
# advanceX 12.4 unsigned fixed-point (uint16_t).
# 12 integer bits, 4 fractional bits = 1/16-pixel resolution.
# Encoded from FreeType's 16.16 linearHoriAdvance.
#
# kernMatrix 4.4 signed fixed-point (int8_t).
# 4 integer bits, 4 fractional bits = 1/16-pixel resolution.
# Range: -8.0 to +7.9375 pixels.
# Encoded from font design-unit kerning values.
#
# Both share 4 fractional bits so the renderer can add them directly into a
# single int32_t accumulator and defer rounding until pixel placement.
def fp4_from_ft16_16(val):
"""Convert FreeType 16.16 fixed-point to 12.4 fixed-point with rounding."""
return (val + (1 << 11)) >> 12
def fp4_from_design_units(du, scale):
"""Convert a font design-unit value to 4.4 fixed-point, clamped to int8_t.
Multiplies by scale (ppem / units_per_em) and shifts into 4 fractional
bits. The result is rounded to nearest and clamped to [-128, 127].
"""
raw = round(du * scale * 16)
return max(-128, min(127, raw))
# Standard Unicode ligature codepoints for known input sequences.
# Used as a fallback when the GSUB substitute glyph has no cmap entry.
STANDARD_LIGATURE_MAP = {
(0x66, 0x66): 0xFB00, # ff
(0x66, 0x69): 0xFB01, # fi
(0x66, 0x6C): 0xFB02, # fl
(0x66, 0x66, 0x69): 0xFB03, # ffi
(0x66, 0x66, 0x6C): 0xFB04, # ffl
(0x17F, 0x74): 0xFB05, # long-s + t
(0x73, 0x74): 0xFB06, # st
}
def _extract_pairpos_subtable(subtable, glyph_to_cp, raw_kern):
"""Extract kerning from a PairPos subtable (Format 1 or 2)."""
if subtable.Format == 1:
# Individual pairs
for i, coverage_glyph in enumerate(subtable.Coverage.glyphs):
if coverage_glyph not in glyph_to_cp:
continue
pair_set = subtable.PairSet[i]
for pvr in pair_set.PairValueRecord:
if pvr.SecondGlyph not in glyph_to_cp:
continue
xa = 0
if hasattr(pvr, 'Value1') and pvr.Value1:
xa = getattr(pvr.Value1, 'XAdvance', 0) or 0
if xa != 0:
key = (coverage_glyph, pvr.SecondGlyph)
raw_kern[key] = raw_kern.get(key, 0) + xa
elif subtable.Format == 2:
# Class-based pairs — iterate by class, not by glyph, to avoid
# O(glyphs²) explosion for CJK fonts with many requested glyphs.
class_def1 = subtable.ClassDef1.classDefs if subtable.ClassDef1 else {}
class_def2 = subtable.ClassDef2.classDefs if subtable.ClassDef2 else {}
coverage_set = set(subtable.Coverage.glyphs)
# Build reverse mappings: class_id -> list of glyph names
left_by_class = {} # only glyphs in coverage AND glyph_to_cp
for glyph in glyph_to_cp:
if glyph not in coverage_set:
continue
c1 = class_def1.get(glyph, 0)
left_by_class.setdefault(c1, []).append(glyph)
right_by_class = {} # all glyphs in glyph_to_cp
for glyph in glyph_to_cp:
c2 = class_def2.get(glyph, 0)
right_by_class.setdefault(c2, []).append(glyph)
# Iterate class pairs (typically << glyph pairs)
for c1, class1_rec in enumerate(subtable.Class1Record):
if c1 not in left_by_class:
continue
for c2, c2_rec in enumerate(class1_rec.Class2Record):
xa = 0
if hasattr(c2_rec, 'Value1') and c2_rec.Value1:
xa = getattr(c2_rec.Value1, 'XAdvance', 0) or 0
if xa == 0:
continue
if c2 not in right_by_class:
continue
for lg in left_by_class[c1]:
for rg in right_by_class[c2]:
key = (lg, rg)
raw_kern[key] = raw_kern.get(key, 0) + xa
def extract_kerning_fonttools(font_path, codepoints, ppem):
"""Extract kerning pairs from a font file using fonttools.
Returns dict of {(leftCp, rightCp): pixel_adjust} for the given
codepoints. Values are scaled from font design units to integer
pixels at ppem.
"""
font = TTFont(font_path)
units_per_em = font['head'].unitsPerEm
cmap = font.getBestCmap() or {}
# Build glyph_name -> [codepoints] map (preserves aliases where multiple
# codepoints share a glyph, e.g. space/nbsp)
glyph_to_cps = {}
for cp in codepoints:
gname = cmap.get(cp)
if gname:
glyph_to_cps.setdefault(gname, []).append(cp)
# Flat dict for membership checks and subtable extraction (uses keys only)
glyph_to_cp = glyph_to_cps
# Collect raw kerning values in font design units
raw_kern = {} # (left_glyph_name, right_glyph_name) -> design_units
# 1. Legacy kern table
if 'kern' in font:
for subtable in font['kern'].kernTables:
if hasattr(subtable, 'kernTable'):
for (lg, rg), val in subtable.kernTable.items():
if lg in glyph_to_cp and rg in glyph_to_cp:
raw_kern[(lg, rg)] = raw_kern.get((lg, rg), 0) + val
# 2. GPOS 'kern' feature
if 'GPOS' in font:
gpos = font['GPOS'].table
kern_lookup_indices = set()
if gpos.FeatureList:
for fr in gpos.FeatureList.FeatureRecord:
if fr.FeatureTag == 'kern':
kern_lookup_indices.update(fr.Feature.LookupListIndex)
for li in kern_lookup_indices:
lookup = gpos.LookupList.Lookup[li]
for st in lookup.SubTable:
actual = st
# Unwrap Extension (lookup type 9) wrappers
if lookup.LookupType == 9 and hasattr(st, 'ExtSubTable'):
actual = st.ExtSubTable
if hasattr(actual, 'Format'):
_extract_pairpos_subtable(actual, glyph_to_cp, raw_kern)
font.close()
# Scale design-unit kerning values to 4.4 fixed-point pixels.
# Expand glyph aliases: if multiple codepoints share a glyph, emit kern
# pairs for all codepoint combinations.
scale = ppem / units_per_em
result = {} # (leftCp, rightCp) -> 4.4 fixed-point adjust
for (lg, rg), du in raw_kern.items():
adjust = fp4_from_design_units(du, scale)
if adjust != 0:
for lcp in glyph_to_cps[lg]:
for rcp in glyph_to_cps[rg]:
result[(lcp, rcp)] = adjust
return result
def derive_kern_classes(kern_map):
"""Derive class-based kerning from a pair map.
Returns (kern_left_classes, kern_right_classes, kern_matrix,
kern_left_class_count, kern_right_class_count) where:
- kern_left_classes: sorted list of (codepoint, classId) tuples
- kern_right_classes: sorted list of (codepoint, classId) tuples
- kern_matrix: flat list of int8 values (left_class_count * right_class_count)
- kern_left_class_count: number of distinct left classes
- kern_right_class_count: number of distinct right classes
"""
if not kern_map:
return [], [], [], 0, 0
all_left_cps = {lcp for lcp, _ in kern_map}
all_right_cps = {rcp for _, rcp in kern_map}
sorted_right_cps = sorted(all_right_cps)
sorted_left_cps = sorted(all_left_cps)
# Group left codepoints by identical adjustment row
left_profile_to_class = {}
left_class_map = {}
left_class_id = 1
for lcp in sorted(all_left_cps):
row = tuple(kern_map.get((lcp, rcp), 0) for rcp in sorted_right_cps)
if row not in left_profile_to_class:
left_profile_to_class[row] = left_class_id
left_class_id += 1
left_class_map[lcp] = left_profile_to_class[row]
# Group right codepoints by identical adjustment column
right_profile_to_class = {}
right_class_map = {}
right_class_id = 1
for rcp in sorted(all_right_cps):
col = tuple(kern_map.get((lcp, rcp), 0) for lcp in sorted_left_cps)
if col not in right_profile_to_class:
right_profile_to_class[col] = right_class_id
right_class_id += 1
right_class_map[rcp] = right_profile_to_class[col]
kern_left_class_count = left_class_id - 1
kern_right_class_count = right_class_id - 1
if kern_left_class_count > 255 or kern_right_class_count > 255:
print(f"WARNING: kerning class count exceeds uint8_t range "
f"(left={kern_left_class_count}, right={kern_right_class_count}), "
f"dropping kerning for this style",
file=sys.stderr)
return ([], [], [], 0, 0)
# Build the class x class matrix
kern_matrix = [0] * (kern_left_class_count * kern_right_class_count)
for (lcp, rcp), adjust in kern_map.items():
lc = left_class_map[lcp] - 1
rc = right_class_map[rcp] - 1
kern_matrix[lc * kern_right_class_count + rc] = adjust
# Build sorted class entry lists
kern_left_classes = sorted(left_class_map.items())
kern_right_classes = sorted(right_class_map.items())
return (kern_left_classes, kern_right_classes, kern_matrix,
kern_left_class_count, kern_right_class_count)
def extract_ligatures_fonttools(font_path, codepoints):
"""Extract ligature substitution pairs from a font file using fonttools.
Returns list of (packed_pair, ligature_codepoint) for the given codepoints.
Multi-character ligatures are decomposed into chained pairs.
"""
font = TTFont(font_path)
cmap = font.getBestCmap() or {}
# Build glyph_name -> codepoint and codepoint -> glyph_name maps
glyph_to_cp = {}
cp_to_glyph = {}
for cp, gname in cmap.items():
glyph_to_cp[gname] = cp
cp_to_glyph[cp] = gname
# Collect raw ligature rules: (sequence_of_codepoints) -> ligature_codepoint
raw_ligatures = {} # tuple of codepoints -> ligature codepoint
if 'GSUB' in font:
gsub = font['GSUB'].table
LIGATURE_FEATURES = ('liga', 'rlig')
liga_lookup_indices = set()
if gsub.FeatureList:
for fr in gsub.FeatureList.FeatureRecord:
if fr.FeatureTag in LIGATURE_FEATURES:
liga_lookup_indices.update(fr.Feature.LookupListIndex)
for li in liga_lookup_indices:
lookup = gsub.LookupList.Lookup[li]
for st in lookup.SubTable:
actual = st
# Unwrap Extension (lookup type 7) wrappers
if lookup.LookupType == 7 and hasattr(st, 'ExtSubTable'):
actual = st.ExtSubTable
# LigatureSubst is lookup type 4
if not hasattr(actual, 'ligatures'):
continue
for first_glyph, ligature_list in actual.ligatures.items():
if first_glyph not in glyph_to_cp:
continue
first_cp = glyph_to_cp[first_glyph]
for lig in ligature_list:
component_cps = []
valid = True
for comp_glyph in lig.Component:
if comp_glyph not in glyph_to_cp:
valid = False
break
component_cps.append(glyph_to_cp[comp_glyph])
if not valid:
continue
seq = tuple([first_cp] + component_cps)
if lig.LigGlyph in glyph_to_cp:
lig_cp = glyph_to_cp[lig.LigGlyph]
elif seq in STANDARD_LIGATURE_MAP:
lig_cp = STANDARD_LIGATURE_MAP[seq]
else:
seq_str = ', '.join(f'U+{cp:04X}' for cp in seq)
print(f"ligatures: WARNING: dropping ligature ({seq_str}) -> "
f"glyph '{lig.LigGlyph}': output glyph has no cmap entry "
f"and input sequence is not in STANDARD_LIGATURE_MAP",
file=sys.stderr)
continue
raw_ligatures[seq] = lig_cp
font.close()
# Filter: only keep ligatures where all input and output codepoints are
# in our generated glyph set
codepoints_set = set(codepoints)
filtered = {}
for seq, lig_cp in raw_ligatures.items():
if lig_cp not in codepoints_set:
continue
if all(cp in codepoints_set for cp in seq):
filtered[seq] = lig_cp
# Decompose into chained pairs
pairs = []
# First pass: collect all 2-codepoint ligatures
two_char = {seq: lig_cp for seq, lig_cp in filtered.items() if len(seq) == 2}
for seq, lig_cp in two_char.items():
packed = (seq[0] << 16) | seq[1]
pairs.append((packed, lig_cp))
# Second pass: decompose 3+ codepoint ligatures into chained pairs
for seq, lig_cp in filtered.items():
if len(seq) < 3:
continue
prefix = seq[:-1]
last_cp = seq[-1]
if prefix in filtered:
intermediate_cp = filtered[prefix]
packed = (intermediate_cp << 16) | last_cp
pairs.append((packed, lig_cp))
else:
print(f"ligatures: skipping {len(seq)}-char ligature "
f"({', '.join(f'U+{cp:04X}' for cp in seq)}) -> U+{lig_cp:04X}: "
f"no intermediate ligature for prefix", file=sys.stderr)
# Sort by packed pair key — on-device lookup uses binary search
pairs.sort(key=lambda p: p[0])
return pairs
def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False):
"""Rasterize all glyphs for one font style. Returns StyleRasterData."""
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
style_label = style_names.get(style_id, str(style_id))
face = freetype.Face(fontfile)
load_flags = freetype.FT_LOAD_RENDER
if force_autohint:
load_flags |= freetype.FT_LOAD_FORCE_AUTOHINT
def load_glyph(code_point):
glyph_index = face.get_char_index(code_point)
if glyph_index > 0:
face.load_glyph(glyph_index, load_flags)
return face
return None
# Validate intervals: remove codepoints not present in the font
print(f" [{style_label}] Validating intervals against font...", file=sys.stderr)
validated_intervals = []
for i_start, i_end in intervals:
start = i_start
for code_point in range(i_start, i_end + 1):
f = load_glyph(code_point)
if f is None:
if start < code_point:
validated_intervals.append((start, code_point - 1))
start = code_point + 1
if start <= i_end:
validated_intervals.append((start, i_end))
intervals = validated_intervals
total_glyphs = sum(end - start + 1 for start, end in intervals)
print(f" [{style_label}] Validated: {len(intervals)} intervals, {total_glyphs} glyphs", file=sys.stderr)
# Set font size at 150 DPI (matching fontconvert.py)
face.set_char_size(size << 6, size << 6, 150, 150)
# Rasterize all glyphs
total_bitmap_size = 0
all_glyphs = []
for i_start, i_end in intervals:
for code_point in range(i_start, i_end + 1):
f = load_glyph(code_point)
if f is None:
glyph = GlyphProps(0, 0, 0, 0, 0, 0, total_bitmap_size, code_point)
all_glyphs.append((glyph, b''))
continue
bitmap = f.glyph.bitmap
# Build 4-bit greyscale bitmap (same logic as fontconvert.py)
pixels4g = []
px = 0
for i, v in enumerate(bitmap.buffer):
x = i % bitmap.width
if x % 2 == 0:
px = (v >> 4)
else:
px = px | (v & 0xF0)
pixels4g.append(px)
px = 0
if x == bitmap.width - 1 and bitmap.width % 2 > 0:
pixels4g.append(px)
px = 0
# Downsample to 2-bit bitmap
pixels2b = []
px = 0
pitch = (bitmap.width // 2) + (bitmap.width % 2)
for y in range(bitmap.rows):
for x in range(bitmap.width):
px = px << 2
bm = pixels4g[y * pitch + (x // 2)]
bm = (bm >> ((x % 2) * 4)) & 0xF
if bm >= 12:
px += 3
elif bm >= 8:
px += 2
elif bm >= 4:
px += 1
if (y * bitmap.width + x) % 4 == 3:
pixels2b.append(px)
px = 0
if (bitmap.width * bitmap.rows) % 4 != 0:
px = px << (4 - (bitmap.width * bitmap.rows) % 4) * 2
pixels2b.append(px)
packed = bytes(pixels2b)
glyph = GlyphProps(
width=bitmap.width,
height=bitmap.rows,
advance_x=fp4_from_ft16_16(f.glyph.linearHoriAdvance),
left=f.glyph.bitmap_left,
top=f.glyph.bitmap_top,
data_length=len(packed),
data_offset=total_bitmap_size,
code_point=code_point,
)
total_bitmap_size += len(packed)
all_glyphs.append((glyph, packed))
# Get font metrics from pipe character (same heuristic as fontconvert.py)
load_glyph(ord('|'))
advanceY = norm_ceil(face.size.height)
ascender = norm_ceil(face.size.ascender)
descender = norm_floor(face.size.descender)
print(f" [{style_label}] Metrics: advanceY={advanceY}, ascender={ascender}, descender={descender}", file=sys.stderr)
print(f" [{style_label}] Bitmap: {total_bitmap_size} bytes ({total_bitmap_size / 1024:.1f} KB)", file=sys.stderr)
# --- Extract kerning and ligatures ---
ppem = size * 150.0 / 72.0
all_cps = set(g.code_point for g, _ in all_glyphs)
kern_map = extract_kerning_fonttools(fontfile, all_cps, ppem)
print(f" [{style_label}] Kerning: {len(kern_map)} pairs extracted", file=sys.stderr)
(kern_left_classes, kern_right_classes, kern_matrix,
kern_left_class_count, kern_right_class_count) = derive_kern_classes(kern_map)
if kern_map:
matrix_size = kern_left_class_count * kern_right_class_count
entries_size = (len(kern_left_classes) + len(kern_right_classes)) * 3
print(f" [{style_label}] Kerning classes: {kern_left_class_count} left, {kern_right_class_count} right, "
f"{matrix_size + entries_size} bytes", file=sys.stderr)
ligature_pairs = extract_ligatures_fonttools(fontfile, all_cps)
if len(ligature_pairs) > 255:
print(f" [{style_label}] WARNING: {len(ligature_pairs)} ligature pairs exceeds uint8_t max (255), truncating",
file=sys.stderr)
ligature_pairs = ligature_pairs[:255]
print(f" [{style_label}] Ligatures: {len(ligature_pairs)} pairs", file=sys.stderr)
return StyleRasterData(
style_id=style_id,
intervals=intervals,
all_glyphs=all_glyphs,
total_bitmap_size=total_bitmap_size,
advanceY=advanceY,
ascender=ascender,
descender=descender,
kern_left_classes=kern_left_classes,
kern_right_classes=kern_right_classes,
kern_matrix=kern_matrix,
kern_left_class_count=kern_left_class_count,
kern_right_class_count=kern_right_class_count,
ligature_pairs=ligature_pairs,
)
# --- Binary packing helpers ---
# EpdGlyph struct: 16 bytes, little-endian
GLYPH_STRUCT_FORMAT = "<BBHhhH2xI"
assert struct.calcsize(GLYPH_STRUCT_FORMAT) == 16
def pack_style_sections(sd):
"""Pack one StyleRasterData into binary section bytearrays.
Returns (intervals_data, glyphs_data, kern_left, kern_right, kern_matrix, ligatures, bitmaps)."""
intervals_data = bytearray()
offset = 0
for i_start, i_end in sd.intervals:
intervals_data += struct.pack("<III", i_start, i_end, offset)
offset += i_end - i_start + 1
glyphs_data = bytearray()
for glyph, packed in sd.all_glyphs:
glyphs_data += struct.pack(GLYPH_STRUCT_FORMAT,
glyph.width, glyph.height, glyph.advance_x,
glyph.left, glyph.top,
glyph.data_length, glyph.data_offset)
kern_left_data = bytearray()
for cp, cls in sd.kern_left_classes:
kern_left_data += struct.pack("<HB", cp, cls)
kern_right_data = bytearray()
for cp, cls in sd.kern_right_classes:
kern_right_data += struct.pack("<HB", cp, cls)
kern_matrix_data = bytearray()
if sd.kern_matrix:
kern_matrix_data = bytearray(struct.pack(f"<{len(sd.kern_matrix)}b", *sd.kern_matrix))
ligature_data = bytearray()
for packed_pair, lig_cp in sd.ligature_pairs:
ligature_data += struct.pack("<II", packed_pair, lig_cp)
bitmap_data = bytearray()
for glyph, packed in sd.all_glyphs:
bitmap_data += packed
assert len(bitmap_data) == sd.total_bitmap_size
return (intervals_data, glyphs_data, kern_left_data, kern_right_data,
kern_matrix_data, ligature_data, bitmap_data)
def style_sections_total_size(sections):
"""Total byte size of all sections returned by pack_style_sections()."""
return sum(len(s) for s in sections)
# --- File writers ---
def generate_cpfont_multistyle(style_fonts, size, intervals, output_path,
force_autohint=False):
"""Generate a multi-style v4 .cpfont file.
style_fonts: dict of {style_id: fontfile_path} e.g. {0: "Regular.ttf", 2: "Italic.ttf"}
"""
MAGIC = b"CPFONT\x00\x00"
HEADER_SIZE = 32
STYLE_TOC_ENTRY_SIZE = 32
flags = 1 # always 2-bit greyscale
style_count = len(style_fonts)
# Rasterize each style
raster_data = {} # style_id -> StyleRasterData
for style_id in sorted(style_fonts.keys()):
fontfile = style_fonts[style_id]
print(f" Rasterizing style {style_id}...", file=sys.stderr)
raster_data[style_id] = rasterize_font_style(
fontfile, size, intervals, style_id=style_id,
force_autohint=force_autohint)
# Pack binary sections for each style
packed_sections = {} # style_id -> tuple of section bytearrays
for style_id, sd in raster_data.items():
packed_sections[style_id] = pack_style_sections(sd)
# Calculate data offsets (after header + TOC)
data_start = HEADER_SIZE + style_count * STYLE_TOC_ENTRY_SIZE
current_offset = data_start
style_offsets = {} # style_id -> absolute file offset
for style_id in sorted(packed_sections.keys()):
style_offsets[style_id] = current_offset
current_offset += style_sections_total_size(packed_sections[style_id])
# Build global header
# V4 header: magic(8) + version(2) + flags(2) + styleCount(1) + reserved(19) = 32
header = struct.pack("<8sHHB19s", MAGIC, CPFONT_VERSION, flags, style_count, bytes(19))
assert len(header) == HEADER_SIZE
# Build style TOC entries
# Each entry: styleId(1) + pad(3) + intervalCount(4) + glyphCount(4) +
# advanceY(1) + ascender(2) + descender(2) + kernL(2) + kernR(2) +
# kernLCls(1) + kernRCls(1) + ligCount(1) + dataOffset(4) + reserved(4) = 32
STYLE_TOC_FORMAT = "<B3xIIBhhHHBBBI4x"
assert struct.calcsize(STYLE_TOC_FORMAT) == STYLE_TOC_ENTRY_SIZE
toc_data = bytearray()
for style_id in sorted(raster_data.keys()):
sd = raster_data[style_id]
if sd.advanceY > 255:
print(f"ERROR: advanceY ({sd.advanceY}) exceeds uint8 range for "
f"style {style_id} size {size}. This likely means the font "
f"size is too large for this format.",
file=sys.stderr)
sys.exit(1)
toc_data += struct.pack(STYLE_TOC_FORMAT,
style_id,
len(sd.intervals), len(sd.all_glyphs),
sd.advanceY, sd.ascender, sd.descender,
len(sd.kern_left_classes), len(sd.kern_right_classes),
sd.kern_left_class_count, sd.kern_right_class_count,
len(sd.ligature_pairs),
style_offsets[style_id])
# Write output
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True)
total_file_size = 0
with open(output_path, "wb") as f:
f.write(header)
f.write(toc_data)
for style_id in sorted(packed_sections.keys()):
for section in packed_sections[style_id]:
f.write(section)
total_file_size = f.tell()
# Print summary
print(f" Output: {output_path} (v4, {style_count} styles)", file=sys.stderr)
print(f" Header+TOC: {HEADER_SIZE + len(toc_data)} bytes", file=sys.stderr)
for style_id in sorted(raster_data.keys()):
sd = raster_data[style_id]
secs = packed_sections[style_id]
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
sname = style_names.get(style_id, str(style_id))
ssize = style_sections_total_size(secs)
print(f" {sname}: {len(sd.all_glyphs)} glyphs, {len(sd.intervals)} intervals, "
f"{ssize} bytes", file=sys.stderr)
print(f" Total: {total_file_size} bytes ({total_file_size / 1024 / 1024:.2f} MB)", file=sys.stderr)
return total_file_size
def main():
parser = argparse.ArgumentParser(
description="Generate .cpfont files for SD card font loading.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"Available interval presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}"
)
# Font file (positional, optional for multi-style mode)
parser.add_argument("fontfile", nargs="?", default=None,
help="Path to the font file (single-style mode).")
parser.add_argument("--intervals", dest="intervals",
help="Comma-separated interval presets (e.g., 'latin-ext,greek,cyrillic').")
parser.add_argument("--size", type=int, dest="size",
help="Single font size to generate.")
parser.add_argument("--sizes", dest="sizes",
help="Comma-separated sizes (e.g., '12,14,16,18').")
parser.add_argument("--style", dest="style", default="regular",
choices=["regular", "bold", "italic", "bolditalic"],
help="Font style for single-style mode (default: regular).")
parser.add_argument("--name", dest="name",
help="Font family name for output filenames (default: derived from font filename).")
parser.add_argument("--force-autohint", dest="force_autohint", action="store_true",
help="Force FreeType auto-hinter instead of native font hinting.")
parser.add_argument("-o", "--output", dest="output",
help="Output file path (for single-size mode).")
parser.add_argument("--output-dir", dest="output_dir",
help="Output directory for multi-size mode.")
parser.add_argument("--list-presets", action="store_true",
help="List available interval presets and exit.")
# Multi-style mode: per-style font file arguments (generates v4 .cpfont)
parser.add_argument("--regular", dest="font_regular",
help="Font file for regular style (enables multi-style v4 mode).")
parser.add_argument("--bold", dest="font_bold",
help="Font file for bold style.")
parser.add_argument("--italic", dest="font_italic",
help="Font file for italic style.")
parser.add_argument("--bolditalic", dest="font_bolditalic",
help="Font file for bold-italic style.")
args = parser.parse_args()
if args.list_presets:
print("Available interval presets:")
for name, ranges in sorted(INTERVAL_PRESETS.items()):
total = sum(e - s + 1 for s, e in ranges)
print(f" {name:15s} {len(ranges)} range(s), ~{total} codepoints")
sys.exit(0)
# Detect multi-style mode
style_fonts = {}
if args.font_regular:
style_fonts[0] = args.font_regular
if args.font_bold:
style_fonts[1] = args.font_bold
if args.font_italic:
style_fonts[2] = args.font_italic
if args.font_bolditalic:
style_fonts[3] = args.font_bolditalic
is_multistyle = len(style_fonts) > 0
fontfile = args.fontfile
# Require --intervals
if not args.intervals:
print("Error: --intervals is required (e.g., --intervals latin-ext,greek,cyrillic)", file=sys.stderr)
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr)
sys.exit(1)
intervals = resolve_intervals(args.intervals)
# Determine sizes
if args.sizes:
sizes = [int(s.strip()) for s in args.sizes.split(",")]
elif args.size:
sizes = [args.size]
else:
print("Error: --size or --sizes is required", file=sys.stderr)
sys.exit(1)
# Validate early: single-style mode requires a font file
if not is_multistyle and not fontfile:
print("Error: fontfile is required in single-style mode", file=sys.stderr)
sys.exit(1)
# Determine font name
if args.name:
font_name = args.name
elif is_multistyle:
# Derive from the regular font file
ref_file = style_fonts[min(style_fonts.keys())]
base = os.path.splitext(os.path.basename(ref_file))[0]
for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic",
"-regular", "-bold", "-italic", "-bolditalic"]:
if base.endswith(suffix):
base = base[:-len(suffix)]
break
font_name = base
else:
base = os.path.splitext(os.path.basename(fontfile))[0]
for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic",
"-regular", "-bold", "-italic", "-bolditalic"]:
if base.endswith(suffix):
base = base[:-len(suffix)]
break
font_name = base
if not is_multistyle:
# Single font file provided: wrap as a single-style v4 font
style_map = {"regular": 0, "bold": 1, "italic": 2, "bolditalic": 3}
style_fonts[style_map[args.style]] = fontfile
# Always generate v4 format
if args.output and len(sizes) != 1:
print("Error: --output can only be used with a single size", file=sys.stderr)
sys.exit(1)
output_dir = args.output_dir if args.output_dir else f"{font_name}/"
total_size = 0
for sz in sizes:
if args.output and len(sizes) == 1:
output_path = args.output
else:
filename = f"{font_name}_{sz}.cpfont"
output_path = os.path.join(output_dir, filename)
print(f"Generating {output_path} (size {sz}, {len(style_fonts)} style(s), v4)...", file=sys.stderr)
total_size += generate_cpfont_multistyle(
style_fonts, sz, intervals, output_path,
force_autohint=args.force_autohint)
print(f"\nTotal: {len(sizes)} files, {total_size / 1024 / 1024:.2f} MB", file=sys.stderr)
if __name__ == "__main__":
main()
+211
View File
@@ -0,0 +1,211 @@
# SD Card Font Families for CrossPoint Reader
#
# This file is the single source of truth for which fonts are generated,
# how they're sourced, and how they're described in the download manifest.
#
# Adding a new font family = adding a block here. No code changes needed.
#
# Fields:
# name: Output family name (used in filenames and on-device UI)
# description: Human-readable description (shown in download UI and manifest)
# intervals: Comma-separated Unicode interval presets for fontconvert_sdcard.py
# sizes: Point sizes to generate
# force_autohint: (optional) Force FreeType auto-hinter instead of native hinting
# styles: Map of style name -> font source
# path: relative to lib/EpdFont (for committed fonts)
# url: download URL (for fonts not in the repo)
#
# Variable fonts:
# Some fonts (Bitter, Inter, Alegreya) are distributed as variable fonts.
# freetype-py can't set variable font axis values, so the build script
# uses fonttools.instancer to extract static instances automatically.
#
# To use a variable font, add a 'variable' key to the style spec with
# axis values to pin:
#
# styles:
# regular: {url: "https://...Font[wght].ttf", variable: {wght: 400}}
# bold: {url: "https://...Font[wght].ttf", variable: {wght: 700}}
#
# Extracted static fonts are cached in instanced_fonts/ (gitignored).
# Requires fonttools: pip install -r requirements.txt
families:
# ── Serif ──────────────────────────────────────────────────────────────
- name: Literata
description: "Screen-optimized serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-BoldItalic.ttf"}
- name: SourceSerif4
description: "Adobe transitional serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-It.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-BoldIt.ttf"}
- name: NotoSerifExtended
description: "Serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-BoldItalic.ttf"}
- name: Merriweather
description: "Warm serif for long-form reading (Latin, Cyrillic)"
intervals: latin-ext,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-BoldItalic.ttf"}
- name: Lora
description: "Calligraphic serif for literary reading (Latin, Cyrillic)"
intervals: latin-ext,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-BoldItalic.ttf"}
- name: GentiumBookPlus
description: "Scholarly serif with wide Unicode coverage (Latin, Greek, Cyrillic, IPA)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-BoldItalic.ttf"}
- name: IBMPlexSerif
description: "Professional serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-BoldItalic.ttf"}
- name: Bitter
description: "Slab serif designed for e-ink (Latin, Cyrillic)"
intervals: latin-ext,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 400}}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 700}}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
# ── Sans-serif ─────────────────────────────────────────────────────────
- name: NotoSansExtended
description: "Sans-serif (Latin, Greek, Cyrillic, Georgian, Armenian, Ethiopic)"
intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic
sizes: [12, 14, 16, 18]
styles:
regular: {path: "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"}
bold: {path: "builtinFonts/source/NotoSans/NotoSans-Bold.ttf"}
italic: {path: "builtinFonts/source/NotoSans/NotoSans-Italic.ttf"}
bolditalic: {path: "builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf"}
- name: Inter
description: "Modern sans-serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}}
- name: SourceSans3
description: "Adobe humanist sans-serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-It.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-BoldIt.ttf"}
- name: IBMPlexSans
description: "IBM corporate sans-serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-BoldItalic.ttf"}
- name: Alegreya
description: "Calligraphic serif/display (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 400}}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 700}}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
# ── Monospace ──────────────────────────────────────────────────────────
- name: IBMPlexMono
description: "Monospace for code and technical reading (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-BoldItalic.ttf"}
- name: SourceCodePro
description: "Adobe monospace with excellent hinting (Latin)"
intervals: latin-ext
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf"}
# ── Accessibility ──────────────────────────────────────────────────────
- name: AtkinsonHyperlegibleNext
description: "Accessibility font for low vision (Latin)"
intervals: latin-ext
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-BoldItalic.ttf"}
- name: LexicaUltralegible
description: "Accessibility font for low vision / dyslexia (Latin)"
intervals: latin-ext
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-BoldItalic.ttf"}
+31
View File
@@ -100,6 +100,37 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
// Apply fixed transforms before any per-line layout work.
applyParagraphIndent();
// Ensure SD card font glyph metrics are loaded before measuring word widths.
// For flash-based fonts isSdCardFont() returns false and this block is skipped
// entirely — no heap allocation. For SD card fonts this reads glyph metadata
// (advanceX only, no bitmaps) for all unique codepoints in this paragraph so
// that calculateWordWidths() can measure text without on-demand SD I/O.
if (renderer.isSdCardFont(fontId)) {
// Reserve upfront so the joined text allocates exactly once. Without this,
// paragraphs with many words trigger a chain of vector-like reallocations
// inside std::string during layout — visible in prewarm timings for SD fonts.
size_t totalSize = hyphenationEnabled ? 1 : 0;
if (!words.empty()) totalSize += words.size() - 1; // inter-word spaces
for (const auto& w : words) totalSize += w.size();
std::string allText;
allText.reserve(totalSize);
for (size_t i = 0; i < words.size(); i++) {
if (i > 0) allText += ' ';
allText += words[i];
}
if (hyphenationEnabled) allText += '-';
// Style mask: only ask the SD font to load advances for styles actually
// used in this paragraph. Style index is the low two bits (regular/bold/
// italic/bold-italic); the underline bit is irrelevant to advance metrics.
uint8_t styleMask = 0;
for (auto s : wordStyles) {
styleMask |= static_cast<uint8_t>(1u << (static_cast<uint8_t>(s) & 0x03));
}
if (styleMask == 0) styleMask = 0x01; // defensive: regular only
renderer.ensureSdCardFontReady(fontId, allText.c_str(), styleMask);
}
const int pageWidth = viewportWidth;
auto wordWidths = calculateWordWidths(renderer, fontId);
+12 -1
View File
@@ -1,5 +1,7 @@
#include "Hyphenator.h"
#include <Utf8.h>
#include <algorithm>
#include <cassert>
#include <vector>
@@ -256,7 +258,16 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
std::vector<Hyphenator::BreakInfo> breaks;
breaks.reserve(indexes.size());
for (const size_t idx : indexes) {
breaks.push_back({byteOffsetForIndex(cps, idx), true});
// CJK characters can break without inserting a visible hyphen.
// Check the codepoint at the break position: if it's a CJK character,
// no hyphen is needed since CJK scripts don't use hyphenation.
bool needsHyphen = true;
if (idx < cps.size() && utf8IsCjkBreakable(cps[idx].value)) {
needsHyphen = false;
} else if (idx > 0 && utf8IsCjkBreakable(cps[idx - 1].value)) {
needsHyphen = false;
}
breaks.push_back({byteOffsetForIndex(cps, idx), needsHyphen});
}
return breaks;
+24 -1
View File
@@ -2,18 +2,35 @@
#include <FontDecompressor.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <cstring>
FontCacheManager::FontCacheManager(const std::map<int, EpdFontFamily>& fontMap) : fontMap_(fontMap) {}
FontCacheManager::FontCacheManager(const std::map<int, EpdFontFamily>& fontMap,
const std::map<int, SdCardFont*>& sdCardFonts)
: fontMap_(fontMap), sdCardFonts_(sdCardFonts) {}
void FontCacheManager::setFontDecompressor(FontDecompressor* d) { fontDecompressor_ = d; }
void FontCacheManager::clearCache() {
if (fontDecompressor_) fontDecompressor_->clearCache();
for (auto& [id, font] : sdCardFonts_) {
font->clearCache();
}
}
void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask) {
// SD card font prewarm path: prewarm all requested styles in one call
auto it = sdCardFonts_.find(fontId);
if (it != sdCardFonts_.end()) {
int missed = it->second->prewarm(utf8Text, styleMask);
if (missed > 0) {
LOG_DBG("FCM", "prewarmCache(SD): %d glyph(s) not found (styleMask=0x%02X)", missed, styleMask);
}
return;
}
// Standard compressed font prewarm path: loop over all requested styles
if (!fontDecompressor_ || fontMap_.count(fontId) == 0) return;
for (uint8_t i = 0; i < 4; i++) {
@@ -30,10 +47,16 @@ void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t st
void FontCacheManager::logStats(const char* label) {
if (fontDecompressor_) fontDecompressor_->logStats(label);
for (auto& [id, font] : sdCardFonts_) {
font->logStats(label);
}
}
void FontCacheManager::resetStats() {
if (fontDecompressor_) fontDecompressor_->resetStats();
for (auto& [id, font] : sdCardFonts_) {
font->resetStats();
}
}
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
+3 -1
View File
@@ -7,10 +7,11 @@
#include <string>
class FontDecompressor;
class SdCardFont;
class FontCacheManager {
public:
explicit FontCacheManager(const std::map<int, EpdFontFamily>& fontMap);
FontCacheManager(const std::map<int, EpdFontFamily>& fontMap, const std::map<int, SdCardFont*>& sdCardFonts);
void setFontDecompressor(FontDecompressor* d);
@@ -45,6 +46,7 @@ class FontCacheManager {
private:
const std::map<int, EpdFontFamily>& fontMap_;
const std::map<int, SdCardFont*>& sdCardFonts_;
FontDecompressor* fontDecompressor_ = nullptr;
enum class ScanMode : uint8_t { None, Scanning };
+59 -1
View File
@@ -3,6 +3,7 @@
#include <FontDecompressor.h>
#include <HalGPIO.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <Utf8.h>
#include <algorithm>
@@ -22,9 +23,34 @@ const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const Ep
// must consume it (draw the glyph) before requesting another bitmap.
return fd->getBitmap(fontData, glyph, glyphIndex);
}
// For SD card fonts, check if the glyph was loaded on demand into the overflow
// buffer. getOverflowBitmap() returns:
// - bitmap pointer for overflow glyphs with bitmap data
// - nullptr for overflow glyphs without bitmap data (e.g. space: width=0, height=0)
// - nullptr for non-overflow glyphs (normal prewarmed path)
// We distinguish overflow-with-no-bitmap from non-overflow by checking isOverflowGlyph().
if (fontData->glyphMissCtx) {
auto* sdFont = SdCardFont::fromMissCtx(fontData->glyphMissCtx);
if (sdFont->isOverflowGlyph(glyph)) {
return sdFont->getOverflowBitmap(glyph); // may be nullptr for zero-width glyphs
}
}
return &fontData->bitmap[glyph->dataOffset];
}
void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask) const {
auto it = sdCardFonts_.find(fontId);
if (it != sdCardFonts_.end()) {
// Augment the persistent advance-only table for layout measurement.
// The table survives across paragraphs/sections (capped per font), so
// repeated indexing of the same SD font amortizes glyph-metric SD reads.
int missed = it->second->buildAdvanceTable(utf8Text, styleMask);
if (missed > 0) {
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
}
}
}
void GfxRenderer::begin() {
frameBuffer = display.getFrameBuffer();
if (!frameBuffer) {
@@ -38,7 +64,12 @@ void GfxRenderer::begin() {
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
}
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
auto result = fontMap.insert({fontId, font});
if (!result.second) {
LOG_ERR("GFX", "Font ID %d already registered, ignoring duplicate", fontId);
}
}
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
// This should always be inlined for better performance
@@ -1040,6 +1071,12 @@ int GfxRenderer::getScreenHeight() const {
}
int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style style) const {
// Advance table fast-path for SD card fonts during layout
auto sdIt = sdCardFonts_.find(fontId);
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
}
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
@@ -1052,6 +1089,14 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
// Advance table fast-path for SD card fonts during layout.
// Kern data is not loaded during layout (consistent with previous metadataOnly behavior),
// so we return just the space advance without kerning.
auto sdIt = sdCardFonts_.find(fontId);
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
}
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
const auto& font = fontIt->second;
@@ -1073,6 +1118,19 @@ int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint3
}
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
// Advance table fast-path for SD card fonts during layout.
// No kerning/ligature lookup — consistent with previous metadataOnly behavior
// where kern/lig data was not loaded.
auto sdIt = sdCardFonts_.find(fontId);
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
int32_t widthFP = 0;
const uint8_t styleIdx = static_cast<uint8_t>(style);
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
widthFP += sdIt->second->getAdvance(cp, styleIdx);
}
return fp4::toPixel(widthFP);
}
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
+22
View File
@@ -4,6 +4,7 @@
#include <HalDisplay.h>
class FontCacheManager;
class SdCardFont;
#include <cstring>
#include <map>
@@ -42,6 +43,11 @@ class GfxRenderer {
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
std::vector<uint8_t*> bwBufferChunks;
std::map<int, EpdFontFamily> fontMap;
// Mutable because ensureSdCardFontReady() is const (called from layout code
// that holds a const GfxRenderer&) but triggers SD card reads and heap
// allocation inside the SdCardFont objects. Same pragmatic compromise as
// fontCacheManager_ below.
mutable std::map<int, SdCardFont*> sdCardFonts_;
// Mutable because drawText() is const but needs to delegate scan-mode
// recording to the (non-const) FontCacheManager. Same pragmatic compromise
@@ -69,9 +75,25 @@ class GfxRenderer {
// Setup
void begin(); // must be called right after display.begin()
void insertFont(int fontId, EpdFontFamily font);
// Clears both the flash-font map and any SD-font registration for fontId.
// Coupled to avoid dangling SdCardFont* in sdCardFonts_ when callers free
// the underlying SdCardFont and forget the SD-side unregister.
void removeFont(int fontId) {
fontMap.erase(fontId);
sdCardFonts_.erase(fontId);
}
void setFontCacheManager(FontCacheManager* m) { fontCacheManager_ = m; }
FontCacheManager* getFontCacheManager() const { return fontCacheManager_; }
const std::map<int, EpdFontFamily>& getFontMap() const { return fontMap; }
void registerSdCardFont(int fontId, SdCardFont* font) { sdCardFonts_[fontId] = font; }
void unregisterSdCardFont(int fontId) { removeFont(fontId); }
void clearSdCardFonts() { sdCardFonts_.clear(); }
const std::map<int, SdCardFont*>& getSdCardFonts() const { return sdCardFonts_; }
bool isSdCardFont(int fontId) const { return sdCardFonts_.count(fontId) > 0; }
// Ensure SD card font glyph data is loaded for the given text. Called from layout code
// (which holds a const GfxRenderer&) before measuring word widths. Safe to call on non-SD fonts (no-op).
// styleMask: bitmask of styles to prepare (bit 0=regular, 1=bold, 2=italic, 3=bold-italic).
void ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask = 0x0F) const;
// Orientation control (affects logical width/height and coordinate transforms)
void setOrientation(const Orientation o) { orientation = o; }
+15
View File
@@ -309,6 +309,21 @@ STR_DELETE_CONFIRM: "Delete this server?"
STR_OPDS_SERVERS: "OPDS Servers"
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
STR_DOWNLOAD_FONTS: "Download Fonts"
STR_FONT_DOWNLOAD: "Font Download"
STR_LOADING_FONT_LIST: "Loading font list..."
STR_NO_FONTS_AVAILABLE: "No fonts available"
STR_FONT_INSTALLED: "Font installed!"
STR_FONT_INSTALL_FAILED: "Font installation failed"
STR_INSTALLED: "Installed"
STR_CONFIRM_DOWNLOAD_PROMPT: "Download?"
STR_SD_CARD_FULL: "Insufficient SD card space"
STR_FILES_LABEL: "Files: "
STR_SIZE_LABEL: "Size: "
STR_REDOWNLOAD: "Re-download"
STR_DOWNLOAD_ALL: "Download / Update All"
STR_ALL_FONTS_INSTALLED: "All fonts installed!"
STR_UPDATE_AVAILABLE: "Update"
STR_CRASH_TITLE: "System Crash"
STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report."
STR_CRASH_REASON: "Crash reason:"
+18
View File
@@ -15,6 +15,24 @@ void utf8TruncateChars(std::string& str, size_t numChars);
// incomplete trailing bytes are excluded.
int utf8SafeTruncateBuffer(const char* buf, int len);
// Returns true for CJK characters that allow line breaks on either side without hyphenation.
// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation,
// and fullwidth forms — the ranges where word boundaries are implicit per character.
inline bool utf8IsCjkBreakable(const uint32_t cp) {
return (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation
|| (cp >= 0x3040 && cp <= 0x309F) // Hiragana
|| (cp >= 0x30A0 && cp <= 0x30FF) // Katakana
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK Extension A
|| (cp >= 0x4E00 && cp <= 0x9FFF) // CJK Unified Ideographs
|| (cp >= 0xAC00 && cp <= 0xD7AF) // Hangul Syllables
|| (cp >= 0xF900 && cp <= 0xFAFF) // CJK Compatibility Ideographs
|| (cp >= 0xFE30 && cp <= 0xFE4F) // CJK Compatibility Forms
|| (cp >= 0xFF01 && cp <= 0xFF60) // Fullwidth Latin / Punctuation
|| (cp >= 0xFF65 && cp <= 0xFFEF) // Halfwidth Katakana / Hangul
|| (cp >= 0x20000 && cp <= 0x2A6DF) // CJK Extension B
|| (cp >= 0x2A700 && cp <= 0x2B73F); // CJK Extension C
}
// Returns true for Unicode combining diacritical marks that should not advance the cursor.
inline bool utf8IsCombiningMark(const uint32_t cp) {
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Generate a fonts.json manifest from a directory of .cpfont files.
Scans the input directory (flat or nested by family) for .cpfont files, reads
their binary headers to extract style metadata, and produces a JSON manifest
suitable for device-initiated font downloads.
Usage:
python3 scripts/generate-font-manifest.py \
--input lib/EpdFont/scripts/output \
--base-url "https://example.com/fonts/" \
--output dist/fonts.json
The input directory may be flat (all .cpfont files in one dir) or nested
(family subdirectories). Family names are derived from filenames using the
convention <FamilyName>_<size>.cpfont.
"""
import argparse
import json
import os
import struct
import sys
from pathlib import Path
# Import canonical version constants from the shared file in lib/EpdFont/scripts/
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib" / "EpdFont" / "scripts"))
from cpfont_version import CPFONT_VERSION, FONTS_MANIFEST_VERSION
# --- .cpfont binary format constants ---
# Global header: 8s magic, H version, H flags, B styleCount, 19x reserved
GLOBAL_HEADER_FORMAT = "<8sHHB19x"
GLOBAL_HEADER_SIZE = struct.calcsize(GLOBAL_HEADER_FORMAT) # 32 bytes
# Style TOC entry: B styleId, 3x pad, I intervalCount, I glyphCount, ...
# We only need the first byte (styleId) from each 32-byte entry.
STYLE_TOC_ENTRY_SIZE = 32
STYLE_TOC_ENTRY_FORMAT = "<B31x"
CPFONT_MAGIC = b"CPFONT\x00\x00"
STYLE_NAMES = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
# Family descriptions can be loaded from the sd-fonts.yaml config
# (via --descriptions-from) or fall back to the family name.
FAMILY_DESCRIPTIONS: dict[str, str] = {}
def load_descriptions_from_yaml(yaml_path: Path) -> dict[str, str]:
"""Load family descriptions from sd-fonts.yaml config."""
try:
import yaml
except ImportError:
print("WARNING: pyyaml not installed, cannot load descriptions from YAML", file=sys.stderr)
return {}
with open(yaml_path) as f:
config = yaml.safe_load(f)
return {f["name"]: f["description"] for f in config.get("families", []) if "description" in f}
def read_cpfont_styles(filepath: Path) -> list[str]:
"""Read style names from a .cpfont file's binary header."""
with open(filepath, "rb") as f:
header_data = f.read(GLOBAL_HEADER_SIZE)
if len(header_data) < GLOBAL_HEADER_SIZE:
print(f" WARNING: {filepath.name} too small, skipping", file=sys.stderr)
return []
magic, version, _flags, style_count = struct.unpack(
GLOBAL_HEADER_FORMAT, header_data
)
if magic != CPFONT_MAGIC:
print(
f" WARNING: {filepath.name} bad magic {magic!r}, skipping",
file=sys.stderr,
)
return []
if version != CPFONT_VERSION:
print(
f" WARNING: {filepath.name} version {version} != {CPFONT_VERSION}, skipping",
file=sys.stderr,
)
return []
styles = []
for _ in range(style_count):
toc_data = f.read(STYLE_TOC_ENTRY_SIZE)
if len(toc_data) < STYLE_TOC_ENTRY_SIZE:
break
(style_id,) = struct.unpack(STYLE_TOC_ENTRY_FORMAT, toc_data)
name = STYLE_NAMES.get(style_id, f"unknown({style_id})")
styles.append(name)
return styles
def parse_filename(filename: str) -> tuple[str, str] | None:
"""Parse '<FamilyName>_<size>.cpfont' into (family, size_str).
Returns None if the filename doesn't match the expected pattern.
"""
if not filename.endswith(".cpfont"):
return None
stem = filename[: -len(".cpfont")]
parts = stem.rsplit("_", 1)
if len(parts) != 2:
return None
family, size_str = parts
if not size_str.isdigit():
return None
return family, size_str
def scan_cpfont_files(input_dir: Path) -> dict[str, list[Path]]:
"""Scan input directory for .cpfont files, grouped by family name.
Handles both flat and nested directory layouts.
"""
families: dict[str, list[Path]] = {}
for path in sorted(input_dir.rglob("*.cpfont")):
if not path.is_file():
continue
parsed = parse_filename(path.name)
if parsed is None:
print(f" WARNING: skipping {path.name} (unexpected filename format)", file=sys.stderr)
continue
family_name = parsed[0]
families.setdefault(family_name, []).append(path)
return families
def build_manifest(
families: dict[str, list[Path]], base_url: str
) -> dict:
"""Build the manifest dict from discovered font families."""
manifest_families = []
for family_name in sorted(families.keys()):
files = families[family_name]
# Read styles from the first file (all files in a family have the
# same styles since they're generated from the same source fonts).
styles = read_cpfont_styles(files[0]) if files else []
# Get description
description = FAMILY_DESCRIPTIONS.get(family_name)
if description is None:
print(
f" WARNING: no description for family '{family_name}', "
f"consider adding one to FAMILY_DESCRIPTIONS in {__file__}",
file=sys.stderr,
)
description = family_name
file_entries = []
for filepath in sorted(files, key=lambda p: p.name):
file_entries.append(
{
"name": filepath.name,
"size": filepath.stat().st_size,
}
)
manifest_families.append(
{
"name": family_name,
"description": description,
"styles": styles,
"files": file_entries,
}
)
return {
"version": FONTS_MANIFEST_VERSION,
"baseUrl": base_url,
"families": manifest_families,
}
def main():
parser = argparse.ArgumentParser(
description="Generate fonts.json manifest from .cpfont files"
)
parser.add_argument(
"--input",
required=True,
help="Directory containing .cpfont files (flat or nested by family)",
)
parser.add_argument(
"--base-url",
required=True,
help="URL prefix for font downloads (device concatenates baseUrl + filename)",
)
parser.add_argument(
"--output",
required=True,
help="Output path for fonts.json",
)
parser.add_argument(
"--descriptions-from",
default=None,
help="Path to sd-fonts.yaml to load family descriptions (default: use family name)",
)
args = parser.parse_args()
input_dir = Path(args.input)
if not input_dir.is_dir():
print(f"ERROR: {input_dir} is not a directory", file=sys.stderr)
sys.exit(1)
# Ensure base URL ends with /
base_url = args.base_url
if not base_url.endswith("/"):
base_url += "/"
# Load descriptions from YAML config if provided
global FAMILY_DESCRIPTIONS
if args.descriptions_from:
desc_path = Path(args.descriptions_from)
if desc_path.exists():
FAMILY_DESCRIPTIONS = load_descriptions_from_yaml(desc_path)
print(f"Loaded {len(FAMILY_DESCRIPTIONS)} descriptions from {desc_path}")
else:
print(f"WARNING: {desc_path} not found, using family names as descriptions", file=sys.stderr)
print(f"Scanning {input_dir} for .cpfont files...")
families = scan_cpfont_files(input_dir)
if not families:
print("ERROR: no .cpfont files found", file=sys.stderr)
sys.exit(1)
print(f"Found {len(families)} font families:")
for name, files in sorted(families.items()):
print(f" {name}: {len(files)} files")
manifest = build_manifest(families, base_url)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
print(f"Wrote {output_path}")
if __name__ == "__main__":
main()
+20
View File
@@ -253,6 +253,19 @@ bool CrossPointSettings::loadFromBinaryFile() {
}
float CrossPointSettings::getReaderLineCompression() const {
// SD card fonts use same compression as Bookerly (the most neutral values)
if (sdFontFamilyName[0] != '\0') {
switch (lineSpacing) {
case TIGHT:
return 0.95f;
case NORMAL:
default:
return 1.0f;
case WIDE:
return 1.1f;
}
}
switch (fontFamily) {
case NOTOSERIF:
default:
@@ -321,6 +334,13 @@ int CrossPointSettings::getRefreshFrequency() const {
}
int CrossPointSettings::getReaderFontId() const {
// Check SD card font first
if (sdFontFamilyName[0] != '\0' && sdFontIdResolver) {
int id = sdFontIdResolver(sdFontResolverCtx, sdFontFamilyName, fontSize);
if (id != 0) return id;
// Fall through to built-in if SD font not found
}
switch (fontFamily) {
case NOTOSERIF:
default:
+10 -1
View File
@@ -97,8 +97,9 @@ class CrossPointSettings {
// Swapped: Next, Previous
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTON_LAYOUT_COUNT };
// Font family options
// Font family options (built-in fonts only; SD card fonts use sdFontFamilyName)
enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT };
static constexpr uint8_t BUILTIN_FONT_COUNT = FONT_FAMILY_COUNT;
// Font size options
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT };
@@ -212,6 +213,8 @@ class CrossPointSettings {
uint8_t fadingFix = 0;
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
uint8_t embeddedStyle = 1;
// SD card font family name (empty = use built-in fontFamily)
char sdFontFamilyName[32] = "";
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
uint8_t showHiddenFiles = 0;
// Image rendering mode in EPUB reader
@@ -226,6 +229,12 @@ class CrossPointSettings {
// Get singleton instance
static CrossPointSettings& getInstance() { return instance; }
// Callback to resolve SD card font IDs. Set by SdCardFontSystem::begin().
// Returns font ID or 0 if not found.
using SdFontIdResolver = int (*)(void* ctx, const char* familyName, uint8_t fontSize);
SdFontIdResolver sdFontIdResolver = nullptr;
void* sdFontResolverCtx = nullptr;
uint16_t getPowerButtonDuration() const {
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
}
+156
View File
@@ -0,0 +1,156 @@
#include "FontInstaller.h"
#include <HalStorage.h>
#include <Logging.h>
#include <cctype>
#include <cstring>
#include "CrossPointSettings.h"
FontInstaller::FontInstaller(SdCardFontRegistry& registry) : registry_(registry) {}
bool FontInstaller::isValidFamilyName(const char* name) {
if (name == nullptr || name[0] == '\0') return false;
// Reject path traversal
if (strstr(name, "..") != nullptr) return false;
if (strchr(name, '/') != nullptr) return false;
if (strchr(name, '\\') != nullptr) return false;
for (const char* p = name; *p != '\0'; ++p) {
char c = *p;
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') {
return false;
}
}
return true;
}
bool FontInstaller::isValidCpfontFilename(const char* name) {
if (name == nullptr || name[0] == '\0') return false;
// Reject path separators / traversal up front. Anything that could escape
// the family directory or refer to a different one is a hard reject.
if (strstr(name, "..") != nullptr) return false;
if (strchr(name, '/') != nullptr) return false;
if (strchr(name, '\\') != nullptr) return false;
// Must end with ".cpfont" exactly.
static constexpr char kExt[] = ".cpfont";
static constexpr size_t kExtLen = sizeof(kExt) - 1;
size_t nameLen = strlen(name);
if (nameLen <= kExtLen) return false;
if (strcmp(name + nameLen - kExtLen, kExt) != 0) return false;
// Basename (before .cpfont) must be alphanumeric + hyphen + underscore only.
// No additional dots — keeps stray "Foo.cpfont.tmp"-style names out.
size_t baseLen = nameLen - kExtLen;
for (size_t i = 0; i < baseLen; ++i) {
char c = name[i];
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') {
return false;
}
}
return true;
}
bool FontInstaller::ensureFamilyDir(const char* familyName) {
// Reuse the family's existing root if installed; otherwise pick the
// default-write root (hidden if no roots exist yet).
const char* root = SdCardFontRegistry::findFamilyRoot(familyName);
if (!root) root = SdCardFontRegistry::defaultWriteRoot();
if (!Storage.exists(root)) {
if (!Storage.mkdir(root)) {
LOG_ERR("FONT", "Failed to create fonts dir: %s", root);
return false;
}
}
char dirPath[160];
snprintf(dirPath, sizeof(dirPath), "%s/%s", root, familyName);
if (!Storage.exists(dirPath)) {
if (!Storage.mkdir(dirPath)) {
LOG_ERR("FONT", "Failed to create family dir: %s", dirPath);
return false;
}
}
return true;
}
bool FontInstaller::validateCpfontFile(const char* path) {
FsFile file;
if (!Storage.openFileForRead("FONT", path, file)) {
LOG_ERR("FONT", "Cannot open for validation: %s", path);
return false;
}
uint8_t magic[CPFONT_MAGIC_LEN];
size_t bytesRead = file.read(magic, CPFONT_MAGIC_LEN);
file.close();
if (bytesRead < CPFONT_MAGIC_LEN) {
LOG_ERR("FONT", "File too small: %s (%zu bytes)", path, bytesRead);
return false;
}
if (memcmp(magic, "CPFONT\0\0", CPFONT_MAGIC_LEN) != 0) {
LOG_ERR("FONT", "Bad magic in: %s", path);
return false;
}
return true;
}
void FontInstaller::buildFontPath(const char* family, const char* filename, char* outBuf, size_t outBufSize) {
// Use the same root selection as ensureFamilyDir: existing install dir wins,
// otherwise the default-write root.
const char* root = SdCardFontRegistry::findFamilyRoot(family);
if (!root) root = SdCardFontRegistry::defaultWriteRoot();
snprintf(outBuf, outBufSize, "%s/%s/%s", root, family, filename);
}
FontInstaller::Error FontInstaller::deleteFamily(const char* familyName) {
if (!isValidFamilyName(familyName)) {
return Error::INVALID_FAMILY_NAME;
}
// A family may exist in either root (or, edge case, both). Remove from both.
const char* roots[] = {SdCardFontRegistry::FONTS_DIR_HIDDEN, SdCardFontRegistry::FONTS_DIR_VISIBLE};
bool removedAny = false;
bool sawAny = false;
for (const char* root : roots) {
char dirPath[160];
snprintf(dirPath, sizeof(dirPath), "%s/%s", root, familyName);
if (!Storage.exists(dirPath)) continue;
sawAny = true;
if (!Storage.removeDir(dirPath)) {
LOG_ERR("FONT", "Failed to remove family dir: %s", dirPath);
return Error::SD_WRITE_ERROR;
}
removedAny = true;
}
if (!sawAny) {
LOG_DBG("FONT", "Family not found in any fonts root: %s", familyName);
return Error::OK; // Already gone
}
(void)removedAny;
// If this was the active font, clear the setting
if (strcmp(SETTINGS.sdFontFamilyName, familyName) == 0) {
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
LOG_DBG("FONT", "Cleared active SD font (deleted family: %s)", familyName);
}
return Error::OK;
}
void FontInstaller::refreshRegistry() { registry_.discover(); }
bool FontInstaller::isFamilyInstalled(const char* familyName) const {
return registry_.findFamily(familyName) != nullptr;
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <SdCardFontRegistry.h>
#include <cstddef>
#include <cstdint>
/// Shared utility for font installation (device download + browser upload).
/// Handles directory creation, file validation, deletion, and registry refresh.
class FontInstaller {
public:
enum class Error {
OK,
INVALID_FAMILY_NAME,
INVALID_FILE,
SD_WRITE_ERROR,
MAX_FAMILIES_REACHED,
};
explicit FontInstaller(SdCardFontRegistry& registry);
/// Validate a family name: alphanumeric + hyphen + underscore only, no path traversal.
static bool isValidFamilyName(const char* name);
/// Validate a .cpfont filename: ends with ".cpfont", no path separators or
/// traversal sequences, basename uses only alphanumeric + hyphen + underscore
/// + dot (only as the extension separator). Rejects "../foo.cpfont" and
/// "evil/foo.cpfont".
static bool isValidCpfontFilename(const char* name);
/// Ensure /<root>/<family>/ exists, where <root> is /.fonts (preferred) or /fonts.
/// Re-uses the existing root if the family is already installed; otherwise
/// creates it under SdCardFontRegistry::defaultWriteRoot().
bool ensureFamilyDir(const char* familyName);
/// Validate a .cpfont file on disk (check magic bytes).
bool validateCpfontFile(const char* path);
/// Build the full SD path for a font file.
/// Writes "/<root>/<family>/<filename>" to outBuf, choosing <root> the same
/// way ensureFamilyDir does (existing install dir, else default-write root).
static void buildFontPath(const char* family, const char* filename, char* outBuf, size_t outBufSize);
/// Delete a family directory and all .cpfont files in it.
/// If the deleted family is the active reader font, clears the setting.
Error deleteFamily(const char* familyName);
/// Re-run registry discovery to pick up new/removed fonts.
void refreshRegistry();
/// Check whether a family name already exists in the registry.
bool isFamilyInstalled(const char* familyName) const;
private:
SdCardFontRegistry& registry_;
static constexpr const char* CPFONT_MAGIC = "CPFONT\0";
static constexpr size_t CPFONT_MAGIC_LEN = 8;
};
+17
View File
@@ -140,6 +140,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
doc["frontButtonConfirm"] = s.frontButtonConfirm;
doc["frontButtonLeft"] = s.frontButtonLeft;
doc["frontButtonRight"] = s.frontButtonRight;
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
doc["fontFamily"] = s.fontFamily;
// SD card font family name — not in SettingsList, save manually
if (s.sdFontFamilyName[0] != '\0') {
doc["sdFontFamilyName"] = s.sdFontFamilyName;
}
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
@@ -224,6 +234,13 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
CrossPointSettings::validateFrontButtonMapping(s);
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
s.fontFamily = clamp(doc["fontFamily"] | (uint8_t)0, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
// SD card font family name — not in SettingsList, load manually
const char* sfn = doc["sdFontFamilyName"] | "";
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
// Language -- stored as code string for stability across enum reorders.
if (doc["language"].is<const char*>()) {
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "SdCardFontSystem.h"
class GfxRenderer;
// Global SD card font system instance (defined in main.cpp).
extern SdCardFontSystem sdFontSystem;
// Ensure the correct SD card font family is loaded for current settings.
// Defined in main.cpp; call before entering the reader or after settings change.
extern void ensureSdFontLoaded();
+109
View File
@@ -0,0 +1,109 @@
#include "SdCardFontSystem.h"
#include <GfxRenderer.h>
#include <Logging.h>
#include "CrossPointSettings.h"
static uint8_t fontSizeEnumFromSettings() {
uint8_t e = SETTINGS.fontSize;
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
return e;
}
void SdCardFontSystem::begin(GfxRenderer& renderer) {
registry_.discover();
// Register this system as the SD font ID resolver in settings.
// Uses a static trampoline since CrossPointSettings stores a plain function pointer.
SETTINGS.sdFontIdResolver = [](void* ctx, const char* familyName, uint8_t fontSizeEnum) -> int {
return static_cast<SdCardFontSystem*>(ctx)->resolveFontId(familyName, fontSizeEnum);
};
SETTINGS.sdFontResolverCtx = this;
// If user has a saved SD font selection, load it
if (SETTINGS.sdFontFamilyName[0] != '\0') {
const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName);
if (family) {
if (manager_.loadFamily(*family, renderer, fontSizeEnumFromSettings())) {
LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName);
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
SETTINGS.sdFontFamilyName[0] = '\0';
}
} else {
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
SETTINGS.sdFontFamilyName[0] = '\0';
}
}
LOG_DBG("SDFS", "SD font system ready (%d families discovered)", registry_.getFamilyCount());
}
void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
// If the web server (or another task) installed/deleted fonts, re-discover.
// Track whether we just re-discovered so we can force a reload below even
// when the wanted family/size still maps to the same point size — the file
// contents on disk may have changed (e.g. user re-uploaded a new build).
const bool registryWasDirty = registryDirty_.exchange(false, std::memory_order_acquire);
if (registryWasDirty) {
LOG_DBG("SDFS", "Registry dirty — re-discovering fonts");
registry_.discover();
}
const char* wantedFamily = SETTINGS.sdFontFamilyName;
const std::string& currentFamily = manager_.currentFamilyName();
const uint8_t sizeEnum = fontSizeEnumFromSettings();
if (wantedFamily[0] == '\0') {
if (!currentFamily.empty()) {
manager_.unloadAll(renderer);
}
return;
}
// Reload if family changed OR if the user-selected size maps to a
// different file than what's currently loaded OR if the registry was
// just rediscovered (file may have been replaced on disk).
bool familyMatches = (currentFamily == wantedFamily);
if (familyMatches) {
const auto* family = registry_.findFamily(wantedFamily);
if (!family) {
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
manager_.unloadAll(renderer);
SETTINGS.sdFontFamilyName[0] = '\0';
return;
}
auto sizes = family->availableSizes();
uint8_t idx = sizeEnum;
if (idx >= sizes.size()) idx = sizes.size() - 1;
uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx];
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return;
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
}
if (!currentFamily.empty()) {
manager_.unloadAll(renderer);
}
const auto* family = registry_.findFamily(wantedFamily);
if (family) {
if (manager_.loadFamily(*family, renderer, sizeEnum)) {
LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily);
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
SETTINGS.sdFontFamilyName[0] = '\0';
}
} else {
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
SETTINGS.sdFontFamilyName[0] = '\0';
}
}
int SdCardFontSystem::resolveFontId(const char* familyName, uint8_t /*fontSizeEnum*/) const {
// The manager loads exactly one size (closest to SETTINGS.fontSize), so the
// enum is implicit — always return the single loaded font ID for this family.
// ensureLoaded() must have been called with the current settings before this.
return manager_.getFontId(familyName);
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <SdCardFontManager.h>
#include <SdCardFontRegistry.h>
#include <atomic>
class GfxRenderer;
/// Facade that owns the SD card font registry, manager, and resolver logic.
/// Hides implementation details behind a single begin() + ensureLoaded() API.
class SdCardFontSystem {
public:
SdCardFontSystem() = default;
SdCardFontSystem(const SdCardFontSystem&) = delete;
SdCardFontSystem& operator=(const SdCardFontSystem&) = delete;
/// Discover SD card fonts and load user's saved selection. Call once during setup.
void begin(GfxRenderer& renderer);
/// Ensure the correct SD font family is loaded for the current settings.
/// Call before entering the reader or after settings change.
/// Also re-discovers if the registry has been marked dirty (e.g. by web upload).
void ensureLoaded(GfxRenderer& renderer);
/// Resolve an SD card font ID from family name + fontSize enum.
/// Returns 0 if not found. Used by CrossPointSettings::getReaderFontId().
int resolveFontId(const char* familyName, uint8_t fontSizeEnum) const;
/// Access the registry (e.g. for settings UI to enumerate available fonts).
const SdCardFontRegistry& registry() const { return registry_; }
/// Non-const access to the registry (for FontInstaller).
SdCardFontRegistry& registry() { return registry_; }
/// Mark the registry as needing re-discovery.
/// Thread-safe: can be called from the web server task.
void markRegistryDirty() { registryDirty_.store(true, std::memory_order_release); }
/// If the registry is dirty, re-scan the SD card now and clear the flag.
/// Used by the web UI so uploaded/deleted fonts appear in the list
/// without waiting for the reader activity to run ensureLoaded().
void refreshIfDirty() {
if (registryDirty_.exchange(false, std::memory_order_acquire)) {
registry_.discover();
}
}
private:
SdCardFontRegistry registry_;
SdCardFontManager manager_;
std::atomic<bool> registryDirty_{false};
};
+101 -3
View File
@@ -2,18 +2,105 @@
#include <HalTiltSensor.h>
#include <I18n.h>
#include <SdCardFontRegistry.h>
#include <algorithm>
#include <cstring>
#include <iterator>
#include <vector>
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h"
// Build the font family setting dynamically. When registry is non-null, SD card fonts
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
// Built-in font labels (StrId)
std::vector<StrId> enumValues = {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC};
// Runtime string labels for SD card fonts
std::vector<std::string> enumStringValues;
// Reserve: first CrossPointSettings::BUILTIN_FONT_COUNT entries use StrId, rest use strings
if (registry) {
const auto& families = registry->getFamilies();
enumStringValues.reserve(families.size());
std::transform(families.begin(), families.end(), std::back_inserter(enumStringValues),
[](const SdCardFontFamilyInfo& f) { return f.name; });
}
// Capture the SD font count for the lambdas
const int sdFontCount = static_cast<int>(enumStringValues.size());
// Total option count = built-in + SD card families
// For the combined enumStringValues: we need all entries as strings (built-in names + SD names)
// The render code checks enumStringValues first, then enumValues. So we build enumStringValues
// with all options when SD fonts are present.
std::vector<std::string> allStringValues;
if (sdFontCount > 0) {
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SERIF));
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SANS));
allStringValues.push_back(I18N.get(StrId::STR_OPEN_DYSLEXIC));
allStringValues.insert(allStringValues.end(), enumStringValues.begin(), enumStringValues.end());
}
SettingInfo s;
s.nameId = StrId::STR_FONT_FAMILY;
s.type = SettingType::ENUM;
s.enumValues = std::move(enumValues);
s.enumStringValues = std::move(allStringValues);
s.key = "fontFamily";
s.category = StrId::STR_CAT_READER;
// Capture registry families by copy for the lambdas
std::vector<std::string> sdFamilyNames;
if (registry) {
const auto& families = registry->getFamilies();
sdFamilyNames.reserve(families.size());
std::transform(families.begin(), families.end(), std::back_inserter(sdFamilyNames),
[](const SdCardFontFamilyInfo& f) { return f.name; });
}
s.valueGetter = [sdFamilyNames]() -> uint8_t {
// If an SD card font is selected, find its index
if (SETTINGS.sdFontFamilyName[0] != '\0') {
for (int i = 0; i < static_cast<int>(sdFamilyNames.size()); i++) {
if (sdFamilyNames[i] == SETTINGS.sdFontFamilyName) {
return static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i);
}
}
// SD font name not found in registry — fall through to built-in
}
return SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
};
s.valueSetter = [sdFamilyNames](uint8_t v) {
if (v < CrossPointSettings::BUILTIN_FONT_COUNT) {
SETTINGS.fontFamily = v;
SETTINGS.sdFontFamilyName[0] = '\0';
} else {
int sdIdx = v - CrossPointSettings::BUILTIN_FONT_COUNT;
if (sdIdx < static_cast<int>(sdFamilyNames.size())) {
strncpy(SETTINGS.sdFontFamilyName, sdFamilyNames[sdIdx].c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
}
}
};
return s;
}
// Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only.
inline const std::vector<SettingInfo>& getSettingsList() {
static const std::vector<SettingInfo> list = [] {
//
// The static list is constructed exactly once (master's optimization, #1086 +
// #1636) so the per-entry SettingInfo cost is paid once. When an
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
// font-family entry is replaced in a per-call copy with a registry-aware
// version. Callers without SD fonts pay only a vector copy.
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) {
static const std::vector<SettingInfo> baseList = [] {
std::vector<SettingInfo> v = {
// --- Display ---
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
@@ -40,6 +127,8 @@ inline const std::vector<SettingInfo>& getSettingsList() {
StrId::STR_CAT_DISPLAY),
// --- Reader ---
// Built-in font-family entry. Replaced per-call with a registry-aware
// version when SD fonts are installed.
SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
StrId::STR_CAT_READER),
@@ -78,6 +167,7 @@ inline const std::vector<SettingInfo>& getSettingsList() {
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
// --- System ---
SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout,
{StrId::STR_MIN_1, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15, StrId::STR_MIN_30},
@@ -149,5 +239,13 @@ inline const std::vector<SettingInfo>& getSettingsList() {
}
return v;
}();
return list;
std::vector<SettingInfo> v = baseList;
if (registry && registry->getFamilyCount() > 0) {
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
if (it != v.end()) {
*it = buildFontFamilySetting(registry);
}
}
return v;
}
+2
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include "OpdsServerStore.h"
#include "SdCardFontGlobals.h"
#include "boot_sleep/BootActivity.h"
#include "boot_sleep/SleepActivity.h"
#include "browser/OpdsBookBrowserActivity.h"
@@ -193,6 +194,7 @@ void ActivityManager::goToBrowser() {
}
void ActivityManager::goToReader(std::string path) {
ensureSdFontLoaded();
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
}
+3 -11
View File
@@ -582,6 +582,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
SETTINGS.imageRendering)) {
LOG_DBG("ERS", "Cache not found, building...");
GUI.drawPopup(renderer, tr(STR_INDEXING));
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
@@ -745,27 +747,19 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
const auto t0 = millis();
auto* fcm = renderer.getFontCacheManager();
fcm->resetStats();
// Font prewarm: scan pass accumulates text, then prewarm, then real render
const uint32_t heapBefore = esp_get_free_heap_size();
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
scope.endScanAndPrewarm();
const uint32_t heapAfter = esp_get_free_heap_size();
fcm->logStats("prewarm");
const auto tPrewarm = millis();
LOG_DBG("ERS", "Heap: before=%lu after=%lu delta=%ld", heapBefore, heapAfter,
(int32_t)heapAfter - (int32_t)heapBefore);
// Force special handling for pages with images when anti-aliasing is on
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar();
fcm->logStats("bw_render");
const auto tBwRender = millis();
if (imagePageWithAA) {
@@ -816,8 +810,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderer.displayGrayBuffer();
const auto tGrayDisplay = millis();
renderer.setRenderMode(GfxRenderer::BW);
fcm->logStats("gray");
// restore the bw data
renderer.restoreBwBuffer();
const auto tBwRestore = millis();
@@ -0,0 +1,426 @@
#include "FontDownloadActivity.h"
#include <ArduinoJson.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include "MappedInputManager.h"
#include "SdCardFontGlobals.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
FontDownloadActivity::FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("FontDownload", renderer, mappedInput), fontInstaller_(sdFontSystem.registry()) {}
// --- Lifecycle ---
void FontDownloadActivity::onEnter() {
Activity::onEnter();
WiFi.mode(WIFI_STA);
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
}
void FontDownloadActivity::onExit() {
Activity::onExit();
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_OFF);
delay(100);
}
void FontDownloadActivity::onWifiSelectionComplete(const bool success) {
if (!success) {
finish();
return;
}
{
RenderLock lock(*this);
state_ = LOADING_MANIFEST;
}
requestUpdateAndWait();
if (!fetchAndParseManifest()) {
{
RenderLock lock(*this);
state_ = ERROR;
}
return;
}
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
selectedIndex_ = 0;
}
}
// --- Manifest fetching ---
bool FontDownloadActivity::fetchAndParseManifest() {
// Download manifest to a temp file on SD card to avoid holding both
// TLS buffers and the full JSON string in RAM simultaneously.
static constexpr const char* MANIFEST_TMP = "/fonts_manifest.tmp";
auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr);
if (result != HttpDownloader::OK) {
LOG_ERR("FONT", "Failed to fetch manifest from %s", FONT_MANIFEST_URL);
errorMessage_ = "Failed to fetch font list";
Storage.remove(MANIFEST_TMP);
return false;
}
// HTTP client is now closed — TLS buffers freed. Parse JSON from file.
FsFile manifestFile;
if (!Storage.openFileForRead("FONT", MANIFEST_TMP, manifestFile)) {
LOG_ERR("FONT", "Failed to open temp manifest");
Storage.remove(MANIFEST_TMP);
errorMessage_ = "Failed to read font list";
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, manifestFile);
manifestFile.close();
Storage.remove(MANIFEST_TMP);
if (err) {
LOG_ERR("FONT", "Manifest parse error: %s", err.c_str());
errorMessage_ = "Invalid font manifest";
return false;
}
int version = doc["version"] | 0;
if (version != FONTS_MANIFEST_VERSION) {
LOG_ERR("FONT", "Unsupported manifest version: %d", version);
errorMessage_ = "Unsupported manifest version";
return false;
}
baseUrl_ = doc["baseUrl"] | "";
families_.clear();
JsonArray familiesArr = doc["families"].as<JsonArray>();
families_.reserve(familiesArr.size());
for (JsonObject fObj : familiesArr) {
ManifestFamily family;
family.name = fObj["name"] | "";
family.description = fObj["description"] | "";
for (JsonVariant s : fObj["styles"].as<JsonArray>()) {
family.styles.push_back(s.as<std::string>());
}
family.totalSize = 0;
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
ManifestFile file;
file.name = fileObj["name"] | "";
file.size = fileObj["size"] | 0;
family.totalSize += file.size;
family.files.push_back(std::move(file));
}
family.installed = fontInstaller_.isFamilyInstalled(family.name.c_str());
// Detect updates by comparing manifest file sizes with files on disk.
// Not a checksum, but a size mismatch reliably indicates a rebuild in practice.
if (family.installed) {
for (const auto& file : family.files) {
char path[128];
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path));
FsFile f;
if (Storage.openFileForRead("FONT", path, f)) {
size_t actual = f.fileSize();
f.close();
if (actual != file.size) {
family.hasUpdate = true;
break;
}
} else {
// File missing on disk but family dir exists — treat as update
family.hasUpdate = true;
break;
}
}
}
families_.push_back(std::move(family));
}
LOG_DBG("FONT", "Manifest loaded: %zu families", families_.size());
return true;
}
// --- Download ---
void FontDownloadActivity::downloadAll() {
for (size_t i = 0; i < families_.size(); i++) {
if (families_[i].installed && !families_[i].hasUpdate) continue;
downloadFamily(families_[i]);
if (state_ == ERROR) return;
}
{
RenderLock lock(*this);
state_ = COMPLETE;
}
}
size_t FontDownloadActivity::totalUninstalledSize() const {
size_t total = 0;
for (const auto& f : families_) {
if (!f.installed || f.hasUpdate) total += f.totalSize;
}
return total;
}
void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
{
RenderLock lock(*this);
state_ = DOWNLOADING;
downloadingFamilyIndex_ = static_cast<int>(&family - families_.data());
currentFileIndex_ = 0;
currentFileTotal_ = family.files.size();
fileProgress_ = 0;
fileTotal_ = 0;
}
requestUpdateAndWait();
if (!fontInstaller_.ensureFamilyDir(family.name.c_str())) {
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = "Failed to create font directory";
return;
}
for (size_t i = 0; i < family.files.size(); i++) {
const auto& file = family.files[i];
{
RenderLock lock(*this);
currentFileIndex_ = i;
fileProgress_ = 0;
fileTotal_ = file.size;
}
requestUpdateAndWait();
char destPath[128];
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), destPath, sizeof(destPath));
std::string url = baseUrl_ + file.name;
auto result = HttpDownloader::downloadToFile(url, destPath, [this](size_t downloaded, size_t total) {
fileProgress_ = downloaded;
fileTotal_ = total;
requestUpdate(true);
});
if (result != HttpDownloader::OK) {
LOG_ERR("FONT", "Download failed: %s (%d)", file.name.c_str(), result);
fontInstaller_.deleteFamily(family.name.c_str());
family.installed = false;
family.hasUpdate = false;
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = "Download failed: " + file.name;
return;
}
if (!fontInstaller_.validateCpfontFile(destPath)) {
LOG_ERR("FONT", "Invalid .cpfont: %s", destPath);
fontInstaller_.deleteFamily(family.name.c_str());
family.installed = false;
family.hasUpdate = false;
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = "Invalid font file: " + file.name;
return;
}
}
fontInstaller_.refreshRegistry();
family.installed = true;
{
RenderLock lock(*this);
state_ = COMPLETE;
}
}
// --- Input handling ---
void FontDownloadActivity::loop() {
if (state_ == FAMILY_LIST) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
buttonNavigator_.onNextRelease([this] {
if (selectedIndex_ < listItemCount() - 1) {
selectedIndex_++;
requestUpdate();
}
});
buttonNavigator_.onPreviousRelease([this] {
if (selectedIndex_ > 0) {
selectedIndex_--;
requestUpdate();
}
});
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (!families_.empty()) {
if (isDownloadAllSelected()) {
downloadAll();
} else {
const auto& family = families_[familyIndexFromList(selectedIndex_)];
if (!family.installed || family.hasUpdate) {
downloadFamily(families_[familyIndexFromList(selectedIndex_)]);
}
}
requestUpdateAndWait();
return;
}
}
} else if (state_ == COMPLETE) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
}
requestUpdate();
}
} else if (state_ == ERROR) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
}
requestUpdate();
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast<int>(families_.size())) {
downloadFamily(families_[downloadingFamilyIndex_]);
requestUpdateAndWait();
return;
} else {
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
}
requestUpdate();
}
}
}
}
// --- Rendering ---
std::string FontDownloadActivity::formatSize(size_t bytes) {
char buf[32];
if (bytes >= 1024 * 1024) {
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
} else if (bytes >= 1024) {
snprintf(buf, sizeof(buf), "%.0f KB", static_cast<double>(bytes) / 1024.0);
} else {
snprintf(buf, sizeof(buf), "%zu B", bytes);
}
return buf;
}
void FontDownloadActivity::render(RenderLock&&) {
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_DOWNLOAD));
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const auto centerY = (pageHeight - lineHeight) / 2;
if (state_ == LOADING_MANIFEST) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_LOADING_FONT_LIST));
} else if (state_ == FAMILY_LIST) {
if (families_.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_NO_FONTS_AVAILABLE));
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
GUI.drawList(
renderer,
Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing},
listItemCount(), selectedIndex_,
[this](int index) -> std::string {
if (index == 0) {
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalUninstalledSize()) + ")";
}
return families_[familyIndexFromList(index)].name;
},
nullptr, nullptr,
[this](int index) -> std::string {
if (index == 0) return "";
const auto& f = families_[familyIndexFromList(index)];
if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
if (f.installed) return tr(STR_INSTALLED);
return f.description;
},
true,
[this](int index) -> bool {
if (index == 0) return false;
const auto& f = families_[familyIndexFromList(index)];
// Dim installed fonts, but not those with updates available
return f.installed && !f.hasUpdate;
});
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
} else if (state_ == DOWNLOADING) {
const auto& family = families_[downloadingFamilyIndex_];
std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + family.name + " (" +
std::to_string(currentFileIndex_ + 1) + "/" + std::to_string(currentFileTotal_) + ")";
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, statusText.c_str());
float progress = 0;
if (fileTotal_ > 0) {
progress = static_cast<float>(fileProgress_) / static_cast<float>(fileTotal_);
}
int barY = centerY + metrics.verticalSpacing;
GUI.drawProgressBar(
renderer,
Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
static_cast<int>(progress * 100), 100);
int percentY = barY + metrics.progressBarHeight + metrics.verticalSpacing;
renderer.drawCenteredText(UI_10_FONT_ID, percentY,
(std::to_string(static_cast<int>(progress * 100)) + "%").c_str());
} else if (state_ == COMPLETE) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else if (state_ == ERROR) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_FONT_INSTALL_FAILED), true,
EpdFontFamily::BOLD);
if (!errorMessage_.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str());
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}
@@ -0,0 +1,91 @@
#pragma once
#include <string>
#include <vector>
#include "FontInstaller.h"
#include "SdCardFont.h"
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
// JSON schema version of the fonts.json manifest. 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 manifest schema.
#define FONTS_MANIFEST_VERSION 1
#ifndef FONT_MANIFEST_URL
// Manifest + .cpfont assets are published by .github/workflows/release-fonts.yml
// to the crosspoint-fonts repo under the "sd-fonts-m<META>-b<BIN>" tag. The tag
// pattern must stay in sync with the workflow; it derives its version numbers
// from lib/EpdFont/scripts/cpfont_version.py.
#define FONT_MANIFEST_URL_STRINGIFY_INNER(x) #x
#define FONT_MANIFEST_URL_STRINGIFY(x) FONT_MANIFEST_URL_STRINGIFY_INNER(x)
#define FONT_MANIFEST_URL \
"https://github.com/crosspoint-reader/crosspoint-fonts/releases/download/sd-fonts-m" FONT_MANIFEST_URL_STRINGIFY( \
FONTS_MANIFEST_VERSION) "-b" FONT_MANIFEST_URL_STRINGIFY(CPFONT_VERSION) "/fonts.json"
#endif
class FontDownloadActivity : public Activity {
public:
explicit FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput);
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool preventAutoSleep() override { return state_ == LOADING_MANIFEST || state_ == DOWNLOADING; }
bool skipLoopDelay() override { return true; }
private:
enum State {
WIFI_SELECTION,
LOADING_MANIFEST,
FAMILY_LIST,
DOWNLOADING,
COMPLETE,
ERROR,
};
struct ManifestFile {
std::string name;
size_t size = 0;
};
struct ManifestFamily {
std::string name;
std::string description;
std::vector<std::string> styles;
std::vector<ManifestFile> files;
size_t totalSize = 0;
bool installed = false;
bool hasUpdate = false;
};
State state_ = WIFI_SELECTION;
FontInstaller fontInstaller_;
ButtonNavigator buttonNavigator_;
// Manifest data
std::string baseUrl_;
std::vector<ManifestFamily> families_;
int selectedIndex_ = 0;
// Download progress
size_t currentFileIndex_ = 0;
size_t currentFileTotal_ = 0;
size_t fileProgress_ = 0;
size_t fileTotal_ = 0;
int downloadingFamilyIndex_ = 0;
std::string errorMessage_;
void onWifiSelectionComplete(bool success);
bool fetchAndParseManifest();
void downloadFamily(ManifestFamily& family);
void downloadAll();
bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); }
int familyIndexFromList(int listIndex) const { return listIndex - 1; }
int listItemCount() const { return families_.empty() ? 0 : static_cast<int>(families_.size()) + 1; }
size_t totalUninstalledSize() const;
static std::string formatSize(size_t bytes);
};
@@ -0,0 +1,126 @@
#include "FontSelectionActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const SdCardFontRegistry* registry)
: Activity("FontSelect", renderer, mappedInput), registry_(registry) {}
void FontSelectionActivity::onEnter() {
Activity::onEnter();
// Build combined font list: built-in + SD card fonts
fonts_.clear();
fonts_.reserve(CrossPointSettings::BUILTIN_FONT_COUNT + (registry_ ? registry_->getFamilyCount() : 0));
fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, 0});
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, 1});
fonts_.push_back({I18N.get(StrId::STR_OPEN_DYSLEXIC), true, 2});
if (registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
fonts_.push_back({families[i].name, false, static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i)});
}
}
// Find current selection
selectedIndex_ = 0;
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == SETTINGS.sdFontFamilyName) {
selectedIndex_ = CrossPointSettings::BUILTIN_FONT_COUNT + i;
break;
}
}
} else {
selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
}
requestUpdate();
}
void FontSelectionActivity::onExit() { Activity::onExit(); }
void FontSelectionActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
return;
}
buttonNavigator_.onNextRelease([this] {
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, static_cast<int>(fonts_.size()));
requestUpdate();
});
buttonNavigator_.onPreviousRelease([this] {
selectedIndex_ = ButtonNavigator::previousIndex(selectedIndex_, static_cast<int>(fonts_.size()));
requestUpdate();
});
}
void FontSelectionActivity::handleSelection() {
const auto& font = fonts_[selectedIndex_];
if (font.settingIndex < CrossPointSettings::BUILTIN_FONT_COUNT) {
SETTINGS.fontFamily = font.settingIndex;
SETTINGS.sdFontFamilyName[0] = '\0';
} else if (registry_) {
int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
const auto& families = registry_->getFamilies();
if (sdIdx < static_cast<int>(families.size())) {
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
}
}
finish();
}
void FontSelectionActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto& metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_FAMILY));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
// Determine which font index is currently active (to mark as "Selected")
int currentFontIndex = 0;
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == SETTINGS.sdFontFamilyName) {
currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + i;
break;
}
}
} else {
currentFontIndex = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
}
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
[this](int index) { return fonts_[index].name; }, nullptr, nullptr,
[this, currentFontIndex](int index) -> std::string { return index == currentFontIndex ? tr(STR_SELECTED) : ""; },
true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,34 @@
#pragma once
#include <SdCardFontRegistry.h>
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
class FontSelectionActivity final : public Activity {
public:
explicit FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const SdCardFontRegistry* registry);
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
void handleSelection();
struct FontEntry {
std::string name;
bool isBuiltin;
uint8_t settingIndex; // index used by valueSetter
};
const SdCardFontRegistry* registry_;
ButtonNavigator buttonNavigator_;
std::vector<FontEntry> fonts_;
int selectedIndex_ = 0;
};
+64 -9
View File
@@ -6,11 +6,14 @@
#include "ButtonRemapActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
#include "FontDownloadActivity.h"
#include "FontSelectionActivity.h"
#include "KOReaderSettingsActivity.h"
#include "LanguageSelectActivity.h"
#include "MappedInputManager.h"
#include "OpdsServerListActivity.h"
#include "OtaUpdateActivity.h"
#include "SdCardFontGlobals.h"
#include "SdFirmwareUpdateActivity.h"
#include "SettingsList.h"
#include "StatusBarSettingsActivity.h"
@@ -21,16 +24,17 @@
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
void SettingsActivity::onEnter() {
Activity::onEnter();
// Build per-category vectors from the shared settings list
void SettingsActivity::rebuildSettingsLists() {
displaySettings.clear();
readerSettings.clear();
controlsSettings.clear();
systemSettings.clear();
for (const auto& setting : getSettingsList()) {
// Pick up any fonts uploaded/deleted over the web server since the last
// reader activity ran — otherwise the font-family picker shows stale list.
sdFontSystem.refreshIfDirty();
for (auto& setting : getSettingsList(&sdFontSystem.registry())) {
if (setting.category == StrId::STR_NONE_OPT) continue;
if (setting.category == StrId::STR_CAT_DISPLAY) {
displaySettings.push_back(setting);
@@ -41,7 +45,6 @@ void SettingsActivity::onEnter() {
} else if (setting.category == StrId::STR_CAT_SYSTEM) {
systemSettings.push_back(setting);
}
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
}
// Append device-only ACTION items
@@ -51,18 +54,41 @@ void SettingsActivity::onEnter() {
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
systemSettings.push_back(SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
// Insert "Download Fonts" right after the font family setting so users discover it naturally
readerSettings.insert(readerSettings.begin() + 1,
SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
// Update currentSettings pointer and count for the active category
switch (selectedCategoryIndex) {
case 0:
currentSettings = &displaySettings;
break;
case 1:
currentSettings = &readerSettings;
break;
case 2:
currentSettings = &controlsSettings;
break;
case 3:
currentSettings = &systemSettings;
break;
}
settingsCount = static_cast<int>(currentSettings->size());
}
void SettingsActivity::onEnter() {
Activity::onEnter();
// Reset selection to first category
selectedCategoryIndex = 0;
selectedSettingIndex = 0;
// Initialize with first category (Display)
currentSettings = &displaySettings;
settingsCount = static_cast<int>(displaySettings.size());
rebuildSettingsLists();
// Trigger first update
requestUpdate();
@@ -159,6 +185,21 @@ void SettingsActivity::toggleCurrentSetting() {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
if (setting.nameId == StrId::STR_FONT_FAMILY) {
// Launch font selection submenu instead of cycling
startActivityForResult(std::make_unique<FontSelectionActivity>(renderer, mappedInput, &sdFontSystem.registry()),
[this](const ActivityResult&) {
SETTINGS.saveToFile();
rebuildSettingsLists();
});
return;
}
const uint8_t totalValues = setting.enumStringValues.empty()
? static_cast<uint8_t>(setting.enumValues.size())
: static_cast<uint8_t>(setting.enumStringValues.size());
const uint8_t cur = setting.valueGetter();
setting.valueSetter((cur + 1) % totalValues);
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
@@ -194,6 +235,13 @@ void SettingsActivity::toggleCurrentSetting() {
case SettingAction::SdFirmwareUpdate:
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::DownloadFonts:
startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
SETTINGS.saveToFile();
rebuildSettingsLists();
});
break;
case SettingAction::Language:
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
break;
@@ -245,6 +293,13 @@ void SettingsActivity::render(RenderLock&&) {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(setting.valuePtr);
valueText = I18N.get(setting.enumValues[value]);
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
const uint8_t value = setting.valueGetter();
if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
valueText = setting.enumStringValues[value];
} else if (value < setting.enumValues.size()) {
valueText = I18N.get(setting.enumValues[value]);
}
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
}
@@ -22,6 +22,7 @@ enum class SettingAction {
CheckForUpdates,
SdFirmwareUpdate,
Language,
DownloadFonts,
};
struct SettingInfo {
@@ -29,6 +30,7 @@ struct SettingInfo {
SettingType type;
uint8_t CrossPointSettings::* valuePtr = nullptr;
std::vector<StrId> enumValues;
std::vector<std::string> enumStringValues; // runtime alternative to StrId enumValues (for SD card fonts etc.)
SettingAction action = SettingAction::None;
struct ValueRange {
@@ -159,6 +161,7 @@ class SettingsActivity final : public Activity {
void enterCategory(int categoryIndex);
void toggleCurrentSetting();
void rebuildSettingsLists();
public:
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
+12 -1
View File
@@ -231,7 +231,8 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
@@ -279,6 +280,16 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
// Apply checkerboard dither to create gray text effect for dimmed items
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
const int titleWidth = renderer.getTextWidth(font, item.c_str());
const int lineH = renderer.getLineHeight(font);
const int tx = rect.x + BaseMetrics::values.contentSidePadding;
for (int py = itemY; py < itemY + lineH; py++)
for (int px = tx; px < tx + titleWidth; px++)
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
}
if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
+2 -2
View File
@@ -138,8 +138,8 @@ class BaseTheme {
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr,
const std::function<UIIcon(int index)>& rowIcon = nullptr,
const std::function<std::string(int index)>& rowValue = nullptr,
bool highlightValue = false) const;
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
const std::function<bool(int index)>& rowDimmed = nullptr) const;
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title,
const char* subtitle = nullptr) const;
virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
+11 -1
View File
@@ -208,7 +208,8 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
@@ -267,6 +268,15 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
// Apply checkerboard dither to create gray text effect for dimmed items
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
const int titleWidth = renderer.getTextWidth(UI_10_FONT_ID, item.c_str());
const int lineH = renderer.getLineHeight(UI_10_FONT_ID);
for (int py = itemY + 7; py < itemY + 7 + lineH; py++)
for (int px = textX; px < textX + titleWidth; px++)
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
}
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
+1 -1
View File
@@ -59,7 +59,7 @@ class LyraTheme : public BaseTheme {
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon, const std::function<std::string(int index)>& rowValue,
bool highlightValue) const override;
bool highlightValue, const std::function<bool(int index)>& rowDimmed = nullptr) const override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const override;
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;
@@ -247,9 +247,11 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
(void)rowIcon;
(void)highlightValue;
(void)rowDimmed;
const bool hasSubtitle = static_cast<bool>(rowSubtitle);
const int titleLineHeight = renderer.getLineHeight(kTitleFontId);
const int subtitleLineHeight = renderer.getLineHeight(kSubtitleFontId);
@@ -56,8 +56,8 @@ class RoundedRaffTheme : public BaseTheme {
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr,
const std::function<UIIcon(int index)>& rowIcon = nullptr,
const std::function<std::string(int index)>& rowValue = nullptr,
bool highlightValue = false) const override;
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
const std::function<bool(int index)>& rowDimmed = nullptr) const override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const override;
bool homeMenuShowsContinueReading() const { return true; }
+18
View File
@@ -16,3 +16,21 @@
#define UI_10_FONT_ID (22918846)
#define UI_12_FONT_ID (1635686837)
#define SMALL_FONT_ID (674098198)
// Font ID 0 is reserved as the "not found" sentinel.
// Guard against any hash accidentally producing 0.
static_assert(NOTOSERIF_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSERIF_14_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSERIF_16_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSERIF_18_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_14_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_16_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_18_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_8_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_10_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_14_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(UI_10_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(UI_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(SMALL_FONT_ID != 0, "Font ID collision with sentinel");
+9 -1
View File
@@ -22,6 +22,7 @@
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "RecentBooksStore.h"
#include "SdCardFontSystem.h"
#include "activities/Activity.h"
#include "activities/ActivityManager.h"
#include "activities/settings/SdFirmwareUpdateActivity.h"
@@ -34,7 +35,8 @@ MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display);
ActivityManager activityManager(renderer, mappedInputManager);
FontDecompressor fontDecompressor;
FontCacheManager fontCacheManager(renderer.getFontMap());
SdCardFontSystem sdFontSystem;
FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts());
// Fonts
EpdFont notoserif14RegularFont(&notoserif_14_regular);
@@ -195,6 +197,8 @@ void enterDeepSleep() {
powerManager.startDeepSleep(gpio);
}
void ensureSdFontLoaded() { sdFontSystem.ensureLoaded(renderer); }
void setupDisplayAndFonts() {
display.begin();
renderer.begin();
@@ -225,6 +229,10 @@ void setupDisplayAndFonts() {
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
// Discover and load SD card fonts
sdFontSystem.begin(renderer);
LOG_DBG("MAIN", "Fonts setup");
}
+222 -5
View File
@@ -11,11 +11,15 @@
#include <algorithm>
#include "CrossPointSettings.h"
#include "FontInstaller.h"
#include "OpdsServerStore.h"
#include "SdCardFontGlobals.h"
#include "SdCardFontSystem.h"
#include "SettingsList.h"
#include "WebDAVHandler.h"
#include "WifiCredentialStore.h"
#include "html/FilesPageHtml.generated.h"
#include "html/FontsPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "html/js/jszip_minJs.generated.h"
@@ -164,6 +168,12 @@ void CrossPointWebServer::begin() {
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
// Font management endpoints
server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); });
server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); });
server->on("/api/fonts/upload", HTTP_POST, [this] { handleFontUpload(); }, [this] { handleFontUploadData(); });
server->on("/api/fonts/delete", HTTP_POST, [this] { handleFontDelete(); });
// OPDS server endpoints
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
@@ -1101,7 +1111,10 @@ void CrossPointWebServer::handleSettingsPage() const {
}
void CrossPointWebServer::handleGetSettings() const {
const auto& settings = getSettingsList();
// Pass the SD font registry so the fontFamily setting's enumStringValues
// includes SD-resident families — otherwise the web API only exposes the
// three built-in fonts.
const auto& settings = getSettingsList(&sdFontSystem.registry());
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
@@ -1136,8 +1149,14 @@ void CrossPointWebServer::handleGetSettings() const {
doc["value"] = static_cast<int>(s.valueGetter());
}
JsonArray options = doc["options"].to<JsonArray>();
for (const auto& opt : s.enumValues) {
options.add(I18N.get(opt));
if (!s.enumStringValues.empty()) {
for (const auto& opt : s.enumStringValues) {
options.add(opt);
}
} else {
for (const auto& opt : s.enumValues) {
options.add(I18N.get(opt));
}
}
break;
}
@@ -1197,7 +1216,7 @@ void CrossPointWebServer::handlePostSettings() {
return;
}
const auto& settings = getSettingsList();
const auto& settings = getSettingsList(&sdFontSystem.registry());
int applied = 0;
for (const auto& s : settings) {
@@ -1215,7 +1234,9 @@ void CrossPointWebServer::handlePostSettings() {
}
case SettingType::ENUM: {
const int val = doc[s.key].as<int>();
if (val >= 0 && val < static_cast<int>(s.enumValues.size())) {
const int maxVal = s.enumStringValues.empty() ? static_cast<int>(s.enumValues.size())
: static_cast<int>(s.enumStringValues.size());
if (val >= 0 && val < maxVal) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
} else if (s.valueSetter) {
@@ -1694,3 +1715,199 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
break;
}
}
// --- Font management handlers ---
void CrossPointWebServer::handleFontsPage() const {
sendHtmlContent(server.get(), FontsPageHtml, sizeof(FontsPageHtml));
LOG_DBG("WEB", "Served fonts page");
}
void CrossPointWebServer::handleFontList() const {
// Pick up any uploads/deletes that happened since the last reader load.
const_cast<SdCardFontSystem&>(sdFontSystem).refreshIfDirty();
const auto& families = sdFontSystem.registry().getFamilies();
JsonDocument doc;
JsonArray arr = doc["families"].to<JsonArray>();
doc["maxFamilies"] = SdCardFontRegistry::MAX_SD_FAMILIES;
for (const auto& family : families) {
JsonObject fObj = arr.add<JsonObject>();
fObj["name"] = family.name;
JsonArray sizes = fObj["sizes"].to<JsonArray>();
for (uint8_t s : family.availableSizes()) {
sizes.add(s);
}
JsonArray files = fObj["files"].to<JsonArray>();
for (const auto& file : family.files) {
JsonObject fileObj = files.add<JsonObject>();
// Extract filename from full path
const char* name = strrchr(file.path.c_str(), '/');
fileObj["name"] = name ? name + 1 : file.path.c_str();
// Stat the file for size
FsFile f;
if (Storage.openFileForRead("WEB", file.path.c_str(), f)) {
fileObj["size"] = static_cast<unsigned long>(f.size());
f.close();
} else {
fileObj["size"] = 0;
}
}
}
String json;
serializeJson(doc, json);
server->send(200, "application/json", json);
}
void CrossPointWebServer::handleFontUploadData() {
HTTPUpload& upload = server->upload();
switch (upload.status) {
case UPLOAD_FILE_START: {
esp_task_wdt_reset();
String family = server->arg("family");
fontUpload.valid = false;
fontUpload.magicChecked = false;
fontUpload.bytesWritten = 0;
fontUpload.bufferPos = 0;
if (!FontInstaller::isValidFamilyName(family.c_str())) {
LOG_ERR("WEB", "Invalid font family name: %s", family.c_str());
break;
}
String filename = upload.filename;
// Validate filename: rejects path traversal (../, /, \) and enforces
// a .cpfont basename of alphanumeric + hyphen + underscore. Without
// this an attacker could supply "../../.crosspoint/settings.json" as
// a "filename" and have it written outside the fonts directory.
if (!FontInstaller::isValidCpfontFilename(filename.c_str())) {
LOG_ERR("WEB", "Invalid font filename: %s", filename.c_str());
break;
}
fontUpload.familyName = family.c_str();
// Create a temporary FontInstaller for directory creation
FontInstaller installer(sdFontSystem.registry());
if (!installer.ensureFamilyDir(family.c_str())) {
LOG_ERR("WEB", "Failed to create font family dir");
break;
}
char path[128];
FontInstaller::buildFontPath(family.c_str(), filename.c_str(), path, sizeof(path));
fontUpload.filePath = path;
if (!Storage.openFileForWrite("WEB", path, fontUpload.file)) {
LOG_ERR("WEB", "Failed to open font file for write: %s", path);
break;
}
fontUpload.valid = true;
LOG_DBG("WEB", "Font upload started: %s -> %s", filename.c_str(), path);
break;
}
case UPLOAD_FILE_WRITE: {
if (!fontUpload.valid) break;
esp_task_wdt_reset();
// Validate magic bytes on first chunk only
if (!fontUpload.magicChecked && upload.currentSize >= 8) {
if (memcmp(upload.buf, "CPFONT\0\0", 8) != 0) {
LOG_ERR("WEB", "Invalid .cpfont magic bytes");
fontUpload.valid = false;
break;
}
fontUpload.magicChecked = true;
}
// Buffer writes for efficiency
size_t remaining = upload.currentSize;
const uint8_t* src = upload.buf;
while (remaining > 0) {
size_t space = FontUploadState::BUFFER_SIZE - fontUpload.bufferPos;
size_t chunk = (remaining < space) ? remaining : space;
memcpy(fontUpload.buffer.data() + fontUpload.bufferPos, src, chunk);
fontUpload.bufferPos += chunk;
src += chunk;
remaining -= chunk;
if (fontUpload.bufferPos >= FontUploadState::BUFFER_SIZE) {
fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos);
fontUpload.bytesWritten += fontUpload.bufferPos;
fontUpload.bufferPos = 0;
esp_task_wdt_reset();
}
}
break;
}
case UPLOAD_FILE_END: {
// Flush remaining buffer
if (fontUpload.valid && fontUpload.bufferPos > 0) {
fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos);
fontUpload.bytesWritten += fontUpload.bufferPos;
fontUpload.bufferPos = 0;
}
fontUpload.file.close();
if (!fontUpload.valid && !fontUpload.filePath.empty()) {
Storage.remove(fontUpload.filePath.c_str());
}
LOG_DBG("WEB", "Font upload end: valid=%d, %zu bytes", fontUpload.valid, fontUpload.bytesWritten);
break;
}
case UPLOAD_FILE_ABORTED: {
fontUpload.file.close();
if (!fontUpload.filePath.empty()) {
Storage.remove(fontUpload.filePath.c_str());
}
fontUpload.valid = false;
LOG_DBG("WEB", "Font upload aborted");
break;
}
}
}
void CrossPointWebServer::handleFontUpload() {
if (fontUpload.valid) {
sdFontSystem.markRegistryDirty();
server->send(200, "application/json", "{\"ok\":true}");
LOG_DBG("WEB", "Font upload complete: %s", fontUpload.filePath.c_str());
} else {
server->send(400, "application/json", "{\"error\":\"Invalid .cpfont file\"}");
}
}
void CrossPointWebServer::handleFontDelete() {
String body = server->arg("plain");
JsonDocument doc;
DeserializationError err = deserializeJson(doc, body);
if (err || !doc["family"].is<const char*>()) {
server->send(400, "application/json", "{\"error\":\"Invalid request\"}");
return;
}
const char* familyName = doc["family"];
FontInstaller installer(sdFontSystem.registry());
auto result = installer.deleteFamily(familyName);
if (result == FontInstaller::Error::OK) {
sdFontSystem.markRegistryDirty();
server->send(200, "application/json", "{\"ok\":true}");
LOG_DBG("WEB", "Deleted font family: %s", familyName);
} else {
server->send(500, "application/json", "{\"error\":\"Delete failed\"}");
LOG_ERR("WEB", "Failed to delete font family: %s", familyName);
}
}
+22
View File
@@ -108,6 +108,28 @@ class CrossPointWebServer {
void handleGetSettings() const;
void handlePostSettings();
// Font management handlers
void handleFontsPage() const;
void handleFontList() const;
void handleFontUpload();
void handleFontUploadData();
void handleFontDelete();
// Font upload state
struct FontUploadState {
FsFile file;
std::string familyName;
std::string filePath;
bool valid = false;
bool magicChecked = false;
size_t bytesWritten = 0;
static constexpr size_t BUFFER_SIZE = 4096;
std::vector<uint8_t> buffer;
size_t bufferPos = 0;
FontUploadState() { buffer.resize(BUFFER_SIZE); }
} fontUpload;
// OPDS server handlers
void handleGetOpdsServers() const;
void handlePostOpdsServer();
+1
View File
@@ -1465,6 +1465,7 @@
<a href="/">Home</a>
<a href="/files" class="active">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts">Fonts</a>
</div>
<div class="page-header">
+323
View File
@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader - Fonts</title>
<style>
:root {
--font-color: #333;
--bg: #f5f5f5;
--title-color: #2c3e50;
--card-bg: #FFF;
--label-color: #7f8c8d;
--border-color: #eee;
--accent-color: rgb(110, 154, 130);
--accent-hover-color: #5a8c73;
--danger-color: #e74c3c;
--danger-hover: #c0392b;
}
@media (prefers-color-scheme: dark) {
:root {
--font-color: #f5f5f5;
--bg: #333;
--title-color: #ecf0f1;
--card-bg: #444;
--label-color: #bdc3c7;
--border-color: #555;
color-scheme: dark;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg);
color: var(--font-color);
}
h1 {
color: var(--title-color);
border-bottom: 2px solid var(--accent-color);
padding-bottom: 10px;
}
h2 { color: var(--title-color); margin-top: 0; }
h3 { margin: 0 0 8px 0; }
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nav-links {
margin: 20px 0;
display: flex;
gap: 10px;
}
.nav-links a {
padding: 10px 20px;
color: var(--font-color);
text-decoration: none;
border-radius: 4px;
}
.nav-links a.active {
background-color: var(--accent-color);
color: white;
}
.nav-links a:not(.active):hover {
background-color: var(--accent-hover-color);
color: white;
}
.family {
border-bottom: 1px solid var(--border-color);
padding: 12px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.family:last-child { border-bottom: none; }
.family-info { flex: 1; }
.family-meta { color: var(--label-color); font-size: 0.9em; }
.btn {
padding: 6px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover { background: var(--danger-hover); }
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover { background: var(--accent-hover-color); }
.upload-form {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.upload-form input[type="text"] {
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg);
color: var(--font-color);
}
.upload-form input[type="file"] { flex: 1; min-width: 200px; }
#status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
display: none;
}
.status-ok { background: #d4edda; color: #155724; display: block !important; }
.status-err { background: #f8d7da; color: #721c24; display: block !important; }
.empty { color: var(--label-color); text-align: center; padding: 20px; }
</style>
</head>
<body>
<h1>📚 CrossPoint Reader</h1>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts" class="active">Fonts</a>
</div>
<div class="card">
<h2>Installed Fonts</h2>
<div id="families"><p class="empty">Loading...</p></div>
</div>
<div class="card">
<h2>Upload Font</h2>
<form class="upload-form" id="uploadForm">
<input type="file" id="fontFiles" webkitdirectory directory multiple required />
<button type="submit" class="btn btn-primary">Upload</button>
</form>
<p id="pickedInfo" class="family-meta" style="margin: 8px 0 0;"></p>
<div id="status"></div>
</div>
<script>
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB';
return bytes + ' B';
}
async function loadFonts() {
const el = document.getElementById('families');
try {
const res = await fetch('/api/fonts');
const data = await res.json();
// Build rows with DOM APIs and textContent so on-device family names
// (which can contain arbitrary characters) cannot break markup or
// execute script via innerHTML / inline onclick interpolation.
el.replaceChildren();
if (!data.families || data.families.length === 0) {
const p = document.createElement('p');
p.className = 'empty';
p.textContent = 'No fonts installed';
el.appendChild(p);
return;
}
for (const f of data.families) {
const row = document.createElement('div');
row.className = 'family';
const info = document.createElement('div');
info.className = 'family-info';
const h3 = document.createElement('h3');
h3.textContent = f.name;
info.appendChild(h3);
const meta = document.createElement('span');
meta.className = 'family-meta';
const sizes = (f.sizes || []).join(', ');
const filesSizes = (f.files || []).map(fi => formatSize(fi.size)).join(' + ');
meta.textContent = sizes + 'pt · ' + filesSizes;
info.appendChild(meta);
const btn = document.createElement('button');
btn.className = 'btn btn-danger';
btn.textContent = 'Delete';
// Capture name in the closure rather than interpolating into onclick.
const familyName = f.name;
btn.addEventListener('click', () => deleteFamily(familyName));
row.appendChild(info);
row.appendChild(btn);
el.appendChild(row);
}
} catch (e) {
el.replaceChildren();
const p = document.createElement('p');
p.className = 'empty';
p.textContent = 'Failed to load font list';
el.appendChild(p);
}
}
async function deleteFamily(name) {
if (!confirm('Delete font family "' + name + '"?')) return;
const status = document.getElementById('status');
status.className = '';
status.style.display = 'block';
status.textContent = 'Deleting ' + name + '...';
try {
const res = await fetch('/api/fonts/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({family: name})
});
if (res.ok) {
status.className = 'status-ok';
status.textContent = 'Deleted "' + name + '".';
} else {
status.className = 'status-err';
status.textContent = 'Failed to delete "' + name + '".';
}
} catch (err) {
status.className = 'status-err';
status.textContent = 'Delete error: ' + err.message;
}
await loadFonts();
}
// Derive family name from a .cpfont filename: take everything before the
// last '-' or '_' (that separator precedes the size suffix, e.g. Bookerly_12.cpfont).
function familyFromFilename(name) {
const stem = name.replace(/\.cpfont$/i, '');
const cut = Math.max(stem.lastIndexOf('-'), stem.lastIndexOf('_'));
return cut > 0 ? stem.slice(0, cut) : stem;
}
// Sanitize to match firmware's [A-Za-z0-9_-]+ pattern.
function sanitizeFamily(raw) {
return raw.replace(/[^A-Za-z0-9_-]/g, '_');
}
function cpfontFilesOnly(fileList) {
return Array.from(fileList).filter(f => /\.cpfont$/i.test(f.name));
}
document.getElementById('fontFiles').addEventListener('change', function() {
const info = document.getElementById('pickedInfo');
const files = cpfontFilesOnly(this.files);
if (files.length === 0) {
info.textContent = 'No .cpfont files found in the selected folder.';
return;
}
const family = sanitizeFamily(familyFromFilename(files[0].name));
info.textContent = files.length + ' file' + (files.length === 1 ? '' : 's') +
' → family "' + family + '"';
});
document.getElementById('uploadForm').addEventListener('submit', async function(e) {
e.preventDefault();
const status = document.getElementById('status');
const files = cpfontFilesOnly(document.getElementById('fontFiles').files);
if (files.length === 0) {
status.className = 'status-err';
status.style.display = 'block';
status.textContent = 'No .cpfont files selected.';
return;
}
// A directory picker may include files from multiple family subfolders.
// Reject that up front — otherwise files[0]'s family is silently reused
// for every upload, corrupting the install layout.
const families = [...new Set(files.map(f => sanitizeFamily(familyFromFilename(f.name))))];
if (families.length !== 1) {
status.className = 'status-err';
status.style.display = 'block';
status.textContent = 'Please select files from a single font family.';
return;
}
const family = families[0];
status.className = '';
status.style.display = 'block';
let uploaded = 0;
for (const file of files) {
status.textContent = 'Uploading ' + (uploaded + 1) + '/' + files.length + ': ' + file.name;
const formData = new FormData();
formData.append('family', family);
formData.append('file', file, file.name);
try {
const res = await fetch('/api/fonts/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!data.ok) {
status.className = 'status-err';
status.textContent = 'Failed on ' + file.name + ': ' + (data.error || 'unknown error');
await loadFonts();
return;
}
} catch (err) {
status.className = 'status-err';
status.textContent = 'Upload error on ' + file.name + ': ' + err.message;
await loadFonts();
return;
}
uploaded++;
}
status.className = 'status-ok';
status.textContent = 'Uploaded ' + uploaded + ' file' + (uploaded === 1 ? '' : 's') +
' to family "' + family + '".';
await loadFonts();
});
loadFonts();
</script>
</body>
</html>
+1
View File
@@ -104,6 +104,7 @@
<a href="/" class="active">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts">Fonts</a>
</div>
<div class="card">
+1
View File
@@ -285,6 +285,7 @@
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings" class="active">Settings</a>
<a href="/fonts">Fonts</a>
</div>
<div id="message" class="message"></div>