Merge branch 'master' into release/1.3.0

This commit is contained in:
Zach Nelson
2026-05-08 21:57:15 -05:00
86 changed files with 6966 additions and 434 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/
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "open-x4-sdk"]
path = open-x4-sdk
url = https://github.com/open-x4-epaper/community-sdk.git
url = https://github.com/crosspoint-reader/community-sdk.git
+14 -5
View File
@@ -104,8 +104,17 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
-DUSE_UTF8_LONG_NAMES=1 // SD card long filename support
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 // Avoid zlib name conflicts
-DXML_GE=0 // Disable XML general entities (security)
-DDESTRUCTOR_CLOSES_FILE=1 // FsFile destructor auto-closes (SdFat)
```
**DESTRUCTOR_CLOSES_FILE implications**:
- SdFat's `FsBaseFile` destructor calls `close()` automatically when the object goes out of scope
- **Do NOT add explicit `file.close()` calls** for local `FsFile` variables — the destructor handles it
- Explicit `close()` is still required in these cases:
1. **Close before delete**: Must close before `Storage.remove()` on the same path
2. **Close before reopen**: Must close before reopening the same `FsFile` variable (e.g., write then reopen for read, or rewrite the same path)
3. **Member variables**: `FsFile` members persist beyond any single function scope, so close at the intended release point (e.g., in `onExit()`)
**SINGLE_BUFFER_MODE implications**:
- Only ONE framebuffer exists (not double-buffered)
- Grayscale rendering requires temporary buffer allocation (`renderer.storeBwBuffer()`)
@@ -145,11 +154,11 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
FsFile file;
if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
// Read from file
file.close(); // Explicit close required
// No file.close() needed — DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
}
```
**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`.
**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`. Do NOT add `file.close()` for local variables (see DESTRUCTOR_CLOSES_FILE above).
---
@@ -167,7 +176,7 @@ if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
### Memory Safety and RAII
* Smart Pointers: Prefer std::unique_ptr. Avoid std::shared_ptr (unnecessary atomic overhead for a single-core RISC-V).
* RAII: Use destructors for cleanup, but call file.close() or vTaskDelete() explicitly for deterministic resource release.
* RAII: Use destructors for cleanup. Call `vTaskDelete()` explicitly for deterministic task release. Do NOT call `file.close()` on local `FsFile` variables — `DESTRUCTOR_CLOSES_FILE=1` handles it at scope exit (see Critical Build Flags).
### ESP32-C3 Platform Pitfalls
@@ -376,13 +385,13 @@ void enterNewActivity(Activity* activity) {
- Activity navigation = `delete` old activity + `new` create next activity
- Any memory allocated in `onEnter()` MUST be freed in `onExit()`
- FreeRTOS tasks MUST be deleted in `onExit()` before activity destruction
- File handles MUST be closed in `onExit()`
- Member `FsFile` handles MUST be closed in `onExit()` (local `FsFile` variables auto-close via destructor)
**Activity Pattern**:
```cpp
void onEnter() { Activity::onEnter(); /* alloc: buffer, tasks */ render(); }
void loop() { mappedInput.update(); /* handle input */ }
void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::onExit(); }
void onExit() { /* free: vTaskDelete, free buffer, close member FsFiles */ Activity::onExit(); }
```
**Critical**: Free resources in reverse order. Delete tasks BEFORE activity destruction.
+20 -2
View File
@@ -21,7 +21,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.6.3 Controls](#363-controls)
- [3.6.4 System](#364-system)
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
- [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup)
- [3.6.6 Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds)
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
- [3.7 Sleep Screen](#37-sleep-screen)
- [4. Reading Mode](#4-reading-mode)
- [Page Turning](#page-turning)
@@ -223,7 +224,24 @@ You can also manage OPDS servers from the web interface while in File Transfer m
2. Open `http://<device-ip>/settings`.
3. Use the **OPDS Servers** card to add, edit, or delete entries.
#### 3.6.6 KOReader Sync Quick Setup
For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds).
#### 3.6.6 Web Settings (WiFi + OPDS)
While in **File Transfer** mode, the web settings page includes management cards for both **WiFi Networks** and **OPDS Servers**.
1. On device: open **File Transfer** and connect to WiFi.
1. In a browser, open `http://<device-ip>/settings` or `http://crosspoint.local`.
1. In **WiFi Networks**, add, edit, or delete saved network entries (SSID + optional password).
1. In **OPDS Servers**, add, edit, or delete OPDS catalogs.
Behavior notes:
- Passwords are never shown back in the web UI after saving.
- Leaving Password blank while editing keeps the existing saved password unchanged.
- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on device-side WiFi connection flow.
#### 3.6.7 KOReader Sync Quick Setup
CrossPoint can sync reading progress with KOReader-compatible sync servers.
It also interoperates with KOReader apps/devices when they use the same server and credentials.
+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.
+1
View File
@@ -53,6 +53,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an
## Ukrainian
- [mirus-ua](https://github.com/mirus-ua)
- [KymAndriy](https://github.com/KymAndriy)
## Belarusian
- [Dexif](https://github.com/dexif)
+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"}
+3 -3
View File
@@ -18,11 +18,11 @@ class BookMetadataCache {
struct SpineEntry {
std::string href;
size_t cumulativeSize;
uint32_t cumulativeSize;
int16_t tocIndex;
SpineEntry() : cumulativeSize(0), tocIndex(-1) {}
SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex)
SpineEntry(std::string href, const uint32_t cumulativeSize, const int16_t tocIndex)
: href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {}
};
@@ -44,7 +44,7 @@ class BookMetadataCache {
private:
std::string cachePath;
size_t lutOffset;
uint32_t lutOffset;
uint16_t spineCount;
uint16_t tocCount;
bool loaded;
+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);
@@ -32,9 +32,15 @@ struct JpegContext {
int dstWidth{0};
int dstHeight{0};
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU)
int32_t fineScaleFP{1 << 16}; // src -> dst mapping
int32_t invScaleFP{1 << 16}; // dst -> src mapping
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU).
// X and Y axes use separate scale factors: the aspect ratio of the output (dstWidth/dstHeight)
// may differ from the source (srcWidth/srcHeight) due to integer rounding of displayHeight.
// Using a single (X-based) scale for both axes causes the wrong srcRow to be skipped
// during nearest-neighbor downscaling, potentially losing critical image content.
int32_t fineScaleFPX{1 << 16}; // X: src -> dst column mapping
int32_t invScaleFPX{1 << 16}; // X: dst -> src column mapping
int32_t fineScaleFPY{1 << 16}; // Y: src -> dst row mapping
int32_t invScaleFPY{1 << 16}; // Y: dst -> src row mapping
PixelCache cache;
bool caching{false};
@@ -125,8 +131,10 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
const bool useDithering = ctx->config->useDithering;
const bool caching = ctx->caching;
const int32_t fineScaleFP = ctx->fineScaleFP;
const int32_t invScaleFP = ctx->invScaleFP;
const int32_t fineScaleFPX = ctx->fineScaleFPX;
const int32_t invScaleFPX = ctx->invScaleFPX;
const int32_t fineScaleFPY = ctx->fineScaleFPY;
const int32_t invScaleFPY = ctx->invScaleFPY;
GfxRenderer& renderer = *ctx->renderer;
const int cfgX = ctx->config->x;
const int cfgY = ctx->config->y;
@@ -137,10 +145,10 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
const int srcYEnd = blockY + blockH;
const int srcXEnd = blockX + validW;
int dstYStart = (int)((int64_t)blockY * fineScaleFP >> FP_SHIFT);
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFP >> FP_SHIFT);
int dstXStart = (int)((int64_t)blockX * fineScaleFP >> FP_SHIFT);
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFP >> FP_SHIFT);
int dstYStart = (int)((int64_t)blockY * fineScaleFPY >> FP_SHIFT);
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFPY >> FP_SHIFT);
int dstXStart = (int)((int64_t)blockX * fineScaleFPX >> FP_SHIFT);
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFPX >> FP_SHIFT);
// Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks)
int clampYMax = ctx->dstHeight;
@@ -165,7 +173,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
}
// === 1:1 fast path: no scaling math ===
if (fineScaleFP == FP_ONE) {
if (fineScaleFPX == FP_ONE && fineScaleFPY == FP_ONE) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
@@ -191,11 +199,11 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
// === Bilinear interpolation (upscale: fineScale > 1.0) ===
// Smooths block boundaries that would otherwise create visible banding
// on progressive JPEG DC-only decode (1/8 resolution upscaled to target).
if (fineScaleFP > FP_ONE) {
if (fineScaleFPX > FP_ONE && fineScaleFPY > FP_ONE) {
// Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1].
// Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping.
int safeXStart = (int)(((int64_t)blockX * fineScaleFP + FP_MASK) >> FP_SHIFT);
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFP >> FP_SHIFT);
int safeXStart = (int)(((int64_t)blockX * fineScaleFPX + FP_MASK) >> FP_SHIFT);
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFPX >> FP_SHIFT);
if (safeXStart < dstXStart) safeXStart = dstXStart;
if (safeXEnd > dstXEnd) safeXEnd = dstXEnd;
if (safeXStart > safeXEnd) safeXEnd = safeXStart;
@@ -204,7 +212,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
const int32_t srcFyFP = dstY * invScaleFP;
const int32_t srcFyFP = dstY * invScaleFPY;
const int32_t fy = srcFyFP & FP_MASK;
const int32_t fyInv = FP_ONE - fy;
int ly0 = (srcFyFP >> FP_SHIFT) - blockY;
@@ -219,7 +227,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
// Left edge (with X boundary clamping)
for (int dstX = dstXStart; dstX < safeXStart; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t srcFxFP = dstX * invScaleFPX;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
@@ -247,7 +255,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
// Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds)
for (int dstX = safeXStart; dstX < safeXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t srcFxFP = dstX * invScaleFPX;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
const int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
@@ -270,7 +278,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
// Right edge (with X boundary clamping)
for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t srcFxFP = dstX * invScaleFPX;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
@@ -301,7 +309,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
const int32_t srcFyFP = dstY * invScaleFP;
const int32_t srcFyFP = dstY * invScaleFPY;
int ly = (srcFyFP >> FP_SHIFT) - blockY;
if (ly < 0) ly = 0;
if (ly >= blockH) ly = blockH - 1;
@@ -309,7 +317,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t srcFxFP = dstX * invScaleFPX;
int lx = (srcFxFP >> FP_SHIFT) - blockX;
if (lx < 0) lx = 0;
if (lx >= validW) lx = validW - 1;
@@ -442,12 +450,22 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption);
}
if (destWidth <= 0 || destHeight <= 0) {
LOG_ERR("JPG", "Degenerate output dimensions %dx%d for %s, skipping render", destWidth, destHeight,
imagePath.c_str());
jpeg->close();
delete jpeg;
return false;
}
ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom;
ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom;
ctx.dstWidth = destWidth;
ctx.dstHeight = destHeight;
ctx.fineScaleFP = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
ctx.invScaleFP = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
ctx.fineScaleFPX = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
ctx.invScaleFPX = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
ctx.fineScaleFPY = (int32_t)((int64_t)destHeight * FP_ONE / ctx.scaledSrcHeight);
ctx.invScaleFPY = (int32_t)((int64_t)ctx.scaledSrcHeight * FP_ONE / destHeight);
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth,
destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth,
+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;
+53
View File
@@ -1,5 +1,6 @@
#include "FsHelpers.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <vector>
@@ -42,6 +43,58 @@ std::string normalisePath(const std::string& path) {
return result;
}
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
// Directories first
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
});
}
bool checkFileExtension(std::string_view fileName, const char* extension) {
const size_t extLen = strlen(extension);
if (fileName.length() < extLen) {
+3
View File
@@ -3,11 +3,14 @@
#include <string>
#include <string_view>
#include <vector>
namespace FsHelpers {
std::string normalisePath(const std::string& path);
void sortFileList(std::vector<std::string>& strs);
/**
* Check if the given filename ends with the specified extension (case-insensitive).
*/
+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; }
+19
View File
@@ -229,6 +229,9 @@ STR_EXAMPLE_BOOK: "Book Title"
STR_PREVIEW: "Preview"
STR_TITLE: "Title"
STR_BATTERY: "Battery"
STR_XTC_STATUS_BAR: "XTC Status Bar"
STR_BOTTOM: "Bottom"
STR_TOP: "Top"
STR_UI_THEME: "UI Theme"
STR_THEME_CLASSIC: "Classic"
STR_THEME_LYRA: "Lyra"
@@ -293,6 +296,7 @@ STR_UPLOAD: "Upload"
STR_BOOK_S_STYLE: "Book's Style"
STR_EMBEDDED_STYLE: "Embedded Style"
STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_SET_SLEEP_COVER: "Set Cover"
STR_FOOTNOTES: "Footnotes"
STR_NO_FOOTNOTES: "No footnotes on this page"
STR_LINK: "[link]"
@@ -305,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:"
+14
View File
@@ -171,6 +171,7 @@ STR_NO_UPDATE: "Ingen uppdatering tillgänglig"
STR_UPDATE_FAILED: "Uppdatering misslyckades"
STR_UPDATE_COMPLETE: "Uppdatering färdig"
STR_POWER_ON_HINT: "Tryck och håll strömknappen för att sätta på igen"
STR_RESTARTING_HINT: "Startar om... Om enheten inte startar om, håll ner strömknappen i några sekunder."
STR_NO_ENTRIES: "Inga poster funna"
STR_DOWNLOADING: "Laddar ner…"
STR_DOWNLOAD_FAILED: "Nedladdning misslyckades"
@@ -324,3 +325,16 @@ STR_KB_HINT_SECONDARY_CHAR: "Håll VÄLJ för sekundärt tecken"
STR_KB_HINT_UPPER_SECONDARY: "Håll VÄLJ för VERSALER eller sekundärt tecken"
STR_KB_HINT_LOWER_SECONDARY: "Håll VÄLJ för gemener eller sekundärt tecken"
STR_KB_HINT_URL_SNIPPETS: "Tryck på URL för URL-fragment"
STR_SD_FIRMWARE_UPDATE: "Uppdatering av firmware från SD-kort"
STR_SELECT_FIRMWARE_FILE: "Välj firmwarefil (.bin)"
STR_NO_BIN_FILES: "Inga .bin-filer hittades"
STR_VALIDATING_FIRMWARE: "Validerar firmware..."
STR_INVALID_FIRMWARE: "Ogiltig firmwarefil"
STR_FIRMWARE_TOO_LARGE: "Firmware för stor för partitionen"
STR_FIRMWARE_TOO_SMALL: "Firmwarefilen är för liten"
STR_FIRMWARE_UPDATE_PROMPT: "Uppdatera firmware?"
STR_FIRMWARE_FILE_OPEN_FAILED: "Kan inte öppna filen"
STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!"
STR_RECOVERY_MODE: "Återställningsläge"
STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den"
+42 -13
View File
@@ -26,7 +26,7 @@ STR_LOADING: "Завантаження..."
STR_LOADING_POPUP: "Завантаження"
STR_WIFI_NETWORKS: "Мережі WiFi"
STR_NO_NETWORKS: "Мереж не знайдено"
STR_NETWORKS_FOUND: "мереж %zu "
STR_NETWORKS_FOUND: "мереж %zu"
STR_SCANNING: "Сканування..."
STR_CONNECTING: "Підключення..."
STR_CONNECTED: "Підключено!"
@@ -84,27 +84,27 @@ STR_SCREEN_MARGIN: "Відступи від країв"
STR_PARA_ALIGNMENT: "Вирівнювання тексту"
STR_HYPHENATION: "Перенесення слів"
STR_TIME_TO_SLEEP: "Перехід в режим сну"
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
STR_REFRESH_FREQ: "Частота оновлення екрану"
STR_KOREADER_SYNC: "Синхронізація KOReader"
STR_CHECK_UPDATES: "Перевірити оновлення"
STR_LANGUAGE: "Мова"
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
STR_CLEAR_READING_CACHE: "Очистити кеш книг"
STR_USERNAME: "Ім'я користувача"
STR_PASSWORD: "Пароль"
STR_SYNC_SERVER_URL: "URL сервера синхронізації"
STR_DOCUMENT_MATCHING: "Зіставлення документів"
STR_SYNC_SERVER_URL: "URL для синхронізації"
STR_DOCUMENT_MATCHING: "Порівняння документів"
STR_AUTHENTICATE: "Автентифікувати"
STR_KOREADER_USERNAME: "Ім'я користувача KOReader"
STR_KOREADER_PASSWORD: "Пароль KOReader"
STR_FILENAME: "Ім'я файлу"
STR_BINARY: "Двійковий"
STR_SET_CREDENTIALS_FIRST: "Спочатку встановіть облікові дані"
STR_BINARY: "Побайтово"
STR_SET_CREDENTIALS_FIRST: "Спочатку вкажіть облікові дані"
STR_WIFI_CONN_FAILED: "Помилка підключення WiFi"
STR_AUTHENTICATING: "Автентифікація..."
STR_AUTH_SUCCESS: "Успішно автентифіковано!"
STR_KOREADER_AUTH: "Автентифікація KOReader"
STR_SYNC_READY: "Синхронізація KOReader готова до використання"
STR_SYNC_READY: "Синхронізація KOReader активована"
STR_AUTH_FAILED: "Помилка автентифікації"
STR_DONE: "Готово"
STR_CLEAR_CACHE_WARNING_1: "Це очистить усі кешовані дані книг."
@@ -179,6 +179,8 @@ STR_UNNAMED: "Без назви"
STR_NO_SERVER_URL: "URL сервера не налаштовано"
STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку"
STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку"
STR_NEXT_PAGE: "Наступна с.»"
STR_PREV_PAGE: "« Попередня с."
STR_NETWORK_PREFIX: "Мережа: "
STR_IP_ADDRESS_PREFIX: "IP адреса: "
STR_ERROR_GENERAL_FAILURE: "Помилка: Загальна помилка"
@@ -198,14 +200,14 @@ STR_OPEN: "Відкрити"
STR_DOWNLOAD: "Завант."
STR_RETRY: "Повтор."
STR_YES: "Так"
STR_NO: "Ні"
STR_SHOW: "Показати"
STR_HIDE: "Сховати"
STR_NO: "Ні"
STR_STATE_ON: "УВІМК"
STR_STATE_OFF: "ВИМК"
STR_NOT_SET: "Не встановлено"
STR_DIR_LEFT: "Ліво"
STR_DIR_RIGHT: "Право"
STR_DIR_LEFT: "Вліво"
STR_DIR_RIGHT: "Вправо"
STR_DIR_UP: "Вгору"
STR_DIR_DOWN: "Вниз"
STR_OK_BUTTON: "OK"
@@ -229,10 +231,12 @@ STR_BATTERY: "Акумулятор"
STR_UI_THEME: "Тема інтерфейсу"
STR_THEME_CLASSIC: "Класична"
STR_THEME_LYRA: "Lyra"
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці"
STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки"
STR_OPDS_BROWSER: "Браузер OPDS"
STR_SEARCH: "Пошук"
STR_COVER_CUSTOM: "Обкл. + власне"
STR_MENU_RECENT_BOOKS: "Останні книги"
STR_NO_RECENT_BOOKS: "Немає останніх книг"
@@ -251,8 +255,8 @@ STR_REMAP_RESET_HINT: "Бічна кнопка Вгору: Скинути до
STR_REMAP_CANCEL_HINT: "Бічна кнопка Вниз: Скасувати налаштування"
STR_HW_BACK_LABEL: "Назад (1-ша кнопка)"
STR_HW_CONFIRM_LABEL: "Підтвердити (2-га кнопка)"
STR_HW_LEFT_LABEL: "Ліво (3-тя кнопка)"
STR_HW_RIGHT_LABEL: "Право (4-та кнопка)"
STR_HW_LEFT_LABEL: "Вліво (3-тя кнопка)"
STR_HW_RIGHT_LABEL: "Вправо (4-та кнопка)"
STR_GO_TO_PERCENT: "Перейти до %"
STR_GO_HOME_BUTTON: "На головну"
STR_SYNC_PROGRESS: "Прогрес синхронізації"
@@ -263,7 +267,7 @@ STR_CHAPTER_PREFIX: "Розділ: "
STR_PAGES_SEPARATOR: " сторінок | "
STR_BOOK_PREFIX: "Книга: "
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
STR_PERCENT_STEP_HINT: "Ліво/Право: 1% Вгору/Вниз: 10%"
STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%"
STR_SYNCING_TIME: "Синхронізація часу..."
STR_CALC_HASH: "Обчислення хешу документа..."
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
@@ -292,6 +296,31 @@ STR_FOOTNOTES: "Примітки"
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
STR_LINK: "[посилання]"
STR_SCREENSHOT_BUTTON: "Знімок екрана"
STR_ADD_SERVER: "Додати сервер"
STR_SERVER_NAME: "Назва сервера"
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
STR_DELETE_SERVER: "Видалити сервер"
STR_DELETE_CONFIRM: "Видалити цей сервер?"
STR_OPDS_SERVERS: "Сервери OPDS"
STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: "
STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)"
STR_CRASH_TITLE: "Збій Системи"
STR_CRASH_DESCRIPTION: "Дані про збій збережено в crash_report.txt. Додайте цей файл до вашого звіту про помилку."
STR_CRASH_REASON: "Причина збою:"
STR_CRASH_NO_REASON: "(Причину не вказано)"
STR_TILT_PAGE_TURN: "Перегортання нахилом"
STR_KB_HINT_MOVE_CURSOR: "Натисніть ВЛІВО / ВПРАВО для переміщення курсору"
STR_KB_HINT_RETURN_CURSOR: "Натисніть ВЛІВО для повернення до курсору"
STR_KB_HINT_HIDE_PASSWORD: "Затисніть ВПРАВО, натисніть [***], щоб приховати пароль"
STR_KB_HINT_SHOW_PASSWORD: "Затисніть ВПРАВО, натисніть [abc], щоб показати пароль"
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Натисніть [***], щоб приховати пароль"
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Натисніть [abc], щоб показати пароль"
STR_KB_HINT_EDIT_ENTRY: "Затисніть ВГОРУ для редагування"
STR_KB_TIPS: "Поради:"
STR_KB_HINT_RETURN_KEYBOARD: "Натисніть ВНИЗ, щоб повернутися до клавіатури"
STR_KB_HINT_EXIT_URL_MODE: "Натисніть [ABC] для виходу з режиму URL"
STR_KB_HINT_CLEAR_TEXT: "Затисніть <--, щоб видалити весь текст"
STR_KB_HINT_SECONDARY_CHAR: "Затисніть ВИБРАТИ для додаткових символів"
STR_KB_HINT_UPPER_SECONDARY: "Затисніть ВИБРАТИ для ВЕЛИКИХ літер / символів"
STR_KB_HINT_LOWER_SECONDARY: "Затисніть ВИБРАТИ для малих літер / символів"
STR_KB_HINT_URL_SNIPPETS: "Натисніть URL для вибору шаблонів"
+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
+57 -30
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Build firmware at selected commits and report flash usage.
Build firmware at selected commits and report flash and RAM usage.
Two modes (mutually exclusive, one required):
@@ -33,6 +33,9 @@ import re
import subprocess
import sys
RAM_RE = re.compile(
r"RAM:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes"
)
FLASH_RE = re.compile(
r"Flash:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes"
)
@@ -93,9 +96,9 @@ def build_firmware(env):
return result.returncode, result.stdout + "\n" + result.stderr
def parse_flash_used(output):
"""Extract used-bytes integer from PlatformIO output, or None."""
m = FLASH_RE.search(output)
def parse_size_line(regex, output):
"""Extract used-bytes integer matching *regex* from PlatformIO output, or None."""
m = regex.search(output)
if m:
return int(m.group(1))
return None
@@ -111,10 +114,10 @@ def write_csv(out, rows, fieldnames):
def format_table(rows):
"""Print rows as an aligned human-readable table to stdout."""
COL_COMMIT = 10
COL_FLASH = 11
COL_SIZE = 11
COL_DELTA = 7
def fmt_flash(val):
def fmt_size(val):
if val == "FAILED":
return "FAILED"
return f"{val:,}"
@@ -126,25 +129,33 @@ def format_table(rows):
header = (
f"{'Commit':<{COL_COMMIT}} "
f"{'Flash':>{COL_FLASH}} "
f"{'Flash':>{COL_SIZE}} "
f"{'Delta':>{COL_DELTA}} "
f"{'RAM':>{COL_SIZE}} "
f"{'Delta':>{COL_DELTA}} "
f"Title"
)
sep = (
f"{BOX_CHAR * COL_COMMIT} "
f"{BOX_CHAR * COL_FLASH} "
f"{BOX_CHAR * COL_SIZE} "
f"{BOX_CHAR * COL_DELTA} "
f"{BOX_CHAR * COL_SIZE} "
f"{BOX_CHAR * COL_DELTA} "
f"{BOX_CHAR * 40}"
)
print(header)
print(sep)
for row in rows:
flash_str = fmt_flash(row["flash_bytes"])
delta_str = fmt_delta(row["delta"])
flash_str = fmt_size(row["flash_bytes"])
flash_d = fmt_delta(row["flash_delta"])
ram_str = fmt_size(row["ram_bytes"])
ram_d = fmt_delta(row["ram_delta"])
print(
f"{row['commit']:<{COL_COMMIT}} "
f"{flash_str:>{COL_FLASH}} "
f"{delta_str:>{COL_DELTA}} "
f"{flash_str:>{COL_SIZE}} "
f"{flash_d:>{COL_DELTA}} "
f"{ram_str:>{COL_SIZE}} "
f"{ram_d:>{COL_DELTA}} "
f"{row['title']}"
)
@@ -173,7 +184,7 @@ def build_commits_from_list(refs):
def main():
parser = argparse.ArgumentParser(
description="Measure firmware flash size across git commits.",
description="Measure firmware flash and RAM size across git commits.",
epilog=(
"Range mode walks every commit between START and END (one branch). "
"List mode builds specific refs that may come from different branches."
@@ -233,19 +244,22 @@ def main():
print(f" Building (env: {args.env})...", file=sys.stderr)
rc, output = build_firmware(args.env)
if rc != 0:
build_failed = rc != 0
if build_failed:
print(f" BUILD FAILED (exit {rc}) -- skipping", file=sys.stderr)
results.append((sha, title, None))
results.append((sha, title, None, None, True))
continue
used = parse_flash_used(output)
if used is None:
flash_used = parse_size_line(FLASH_RE, output)
ram_used = parse_size_line(RAM_RE, output)
if flash_used is None:
print(" Could not parse flash size from output -- skipping", file=sys.stderr)
results.append((sha, title, None))
results.append((sha, title, None, None, True))
continue
print(f" Flash used: {used:,} bytes", file=sys.stderr)
results.append((sha, title, used))
ram_str = f", RAM: {ram_used:,}" if ram_used is not None else ""
print(f" Flash: {flash_used:,}{ram_str} bytes", file=sys.stderr)
results.append((sha, title, flash_used, ram_used, False))
except KeyboardInterrupt:
print("\n[info] Interrupted -- writing partial results.", file=sys.stderr)
@@ -258,22 +272,35 @@ def main():
# Build result rows with deltas
rows = []
prev_size = None
for sha, title, used in results:
if used is not None and prev_size is not None:
delta = used - prev_size
prev_flash = None
prev_ram = None
for sha, title, flash_used, ram_used, build_failed in results:
flash_delta = ""
ram_delta = ""
if flash_used is not None and prev_flash is not None:
flash_delta = flash_used - prev_flash
if ram_used is not None and prev_ram is not None:
ram_delta = ram_used - prev_ram
if build_failed:
flash_bytes = "FAILED"
ram_bytes = "FAILED"
else:
delta = ""
flash_bytes = flash_used if flash_used is not None else "N/A"
ram_bytes = ram_used if ram_used is not None else "N/A"
rows.append({
"commit": sha[:10],
"title": title,
"flash_bytes": used if used is not None else "FAILED",
"delta": delta,
"flash_bytes": flash_bytes,
"flash_delta": flash_delta,
"ram_bytes": ram_bytes,
"ram_delta": ram_delta,
})
if used is not None:
prev_size = used
if flash_used is not None:
prev_flash = flash_used
if ram_used is not None:
prev_ram = ram_used
fieldnames = ["commit", "title", "flash_bytes", "delta"]
fieldnames = ["commit", "title", "flash_bytes", "flash_delta", "ram_bytes", "ram_delta"]
if args.csv is not None:
if args.csv == "-":
+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:
+17 -1
View File
@@ -57,6 +57,12 @@ class CrossPointSettings {
STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT
};
enum STATUS_BAR_TITLE { BOOK_TITLE = 0, CHAPTER_TITLE = 1, HIDE_TITLE = 2, STATUS_BAR_TITLE_COUNT };
enum XTC_STATUS_BAR_MODE {
XTC_STATUS_BAR_HIDE = 0,
XTC_STATUS_BAR_BOTTOM = 1,
XTC_STATUS_BAR_TOP = 2,
XTC_STATUS_BAR_MODE_COUNT
};
enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default)
@@ -91,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 };
@@ -161,6 +168,7 @@ class CrossPointSettings {
uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL;
uint8_t statusBarTitle = CHAPTER_TITLE;
uint8_t statusBarBattery = 1;
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
uint8_t textAntiAliasing = 1;
@@ -205,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
@@ -219,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};
};
+104 -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},
@@ -131,6 +221,9 @@ inline const std::vector<SettingInfo>& getSettingsList() {
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery",
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode,
{StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode",
StrId::STR_CUSTOMISE_STATUS_BAR),
};
// Only show tilt page turn setting when the QMI8658 IMU is present (X3)
if (halTiltSensor.isAvailable()) {
@@ -146,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;
}
+9 -1
View File
@@ -2,7 +2,10 @@
#include <HalPowerManager.h>
#include <algorithm>
#include "OpdsServerStore.h"
#include "SdCardFontGlobals.h"
#include "boot_sleep/BootActivity.h"
#include "boot_sleep/SleepActivity.h"
#include "browser/OpdsBookBrowserActivity.h"
@@ -191,6 +194,7 @@ void ActivityManager::goToBrowser() {
}
void ActivityManager::goToReader(std::string path) {
ensureSdFontLoaded();
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
}
@@ -230,7 +234,11 @@ void ActivityManager::popActivity() {
bool ActivityManager::preventAutoSleep() const { return currentActivity && currentActivity->preventAutoSleep(); }
bool ActivityManager::isReaderActivity() const { return currentActivity && currentActivity->isReaderActivity(); }
bool ActivityManager::isReaderActivity() const {
return std::any_of(stackActivities.begin(), stackActivities.end(),
[](const auto& activity) { return activity->isReaderActivity(); }) ||
(currentActivity && currentActivity->isReaderActivity());
}
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
+33 -18
View File
@@ -49,6 +49,23 @@ void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /.sleep (preferred) or /sleep directory
const char* sleepDir = nullptr;
auto dir = Storage.open("/.sleep");
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
// This takes priority over the /sleep folder.
FsFile file;
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("SLP", "Loading: /sleep.bmp");
renderBitmapSleepScreen(bitmap);
file.close();
if (dir) dir.close();
return;
}
file.close();
}
if (dir && dir.isDirectory()) {
sleepDir = "/.sleep";
} else {
@@ -62,26 +79,31 @@ void SleepActivity::renderCustomSleepScreen() const {
std::vector<std::string> files;
char name[500];
// collect all valid BMP files
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
if (file.isDirectory()) {
for (auto dirFile = dir.openNextFile(); dirFile; dirFile = dir.openNextFile()) {
if (dirFile.isDirectory()) {
dirFile.close();
continue;
}
file.getName(name, sizeof(name));
dirFile.getName(name, sizeof(name));
auto filename = std::string(name);
if (filename[0] == '.') {
dirFile.close();
continue;
}
if (!FsHelpers::hasBmpExtension(filename)) {
LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name);
dirFile.close();
continue;
}
Bitmap bitmap(file);
Bitmap bitmap(dirFile);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
LOG_DBG("SLP", "Skipping invalid BMP file: %s", name);
dirFile.close();
continue;
}
files.emplace_back(filename);
dirFile.close();
}
const auto numFiles = files.size();
if (numFiles > 0) {
@@ -97,29 +119,22 @@ void SleepActivity::renderCustomSleepScreen() const {
APP_STATE.pushRecentSleep(randomFileIndex);
APP_STATE.saveToFile();
const auto filename = std::string(sleepDir) + "/" + files[randomFileIndex];
FsFile file;
if (Storage.openFileForRead("SLP", filename, file)) {
FsFile randFile;
if (Storage.openFileForRead("SLP", filename, randFile)) {
LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str());
delay(100);
Bitmap bitmap(file, true);
Bitmap bitmap(randFile, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap);
randFile.close();
dir.close();
return;
}
randFile.close();
}
}
}
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
FsFile file;
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("SLP", "Loading: /sleep.bmp");
renderBitmapSleepScreen(bitmap);
return;
}
}
if (dir) dir.close();
renderDefaultSleepScreen();
}
+11 -59
View File
@@ -18,58 +18,6 @@ namespace {
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
// Directories first
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
});
}
void FileBrowserActivity::loadFiles() {
files.clear();
@@ -103,7 +51,8 @@ void FileBrowserActivity::loadFiles() {
}
}
}
sortFileList(files);
root.close();
FsHelpers::sortFileList(files);
}
void FileBrowserActivity::onEnter() {
@@ -190,17 +139,20 @@ void FileBrowserActivity::loop() {
return;
}
if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) {
// --- LONG PRESS ACTION: DELETE FILE ---
if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS) {
// --- LONG PRESS ACTION: DELETE FILE OR DIRECTORY ---
std::string cleanBasePath = basepath;
if (cleanBasePath.back() != '/') cleanBasePath += "/";
const std::string fullPath = cleanBasePath + entry;
auto handler = [this, fullPath](const ActivityResult& res) {
auto handler = [this, fullPath, isDirectory](const ActivityResult& res) {
if (!res.isCancelled) {
LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str());
clearFileMetadata(fullPath);
if (Storage.remove(fullPath.c_str())) {
if (!isDirectory) {
clearFileMetadata(fullPath);
}
const bool deleted = isDirectory ? Storage.removeDir(fullPath.c_str()) : Storage.remove(fullPath.c_str());
if (deleted) {
LOG_DBG("FileBrowser", "Deleted successfully");
loadFiles();
if (files.empty()) {
@@ -212,7 +164,7 @@ void FileBrowserActivity::loop() {
requestUpdate(true);
} else {
LOG_ERR("FileBrowser", "Failed to delete file: %s", fullPath.c_str());
LOG_ERR("FileBrowser", "Failed to delete: %s", fullPath.c_str());
}
} else {
LOG_DBG("FileBrowser", "Delete cancelled by user");
+1 -1
View File
@@ -235,7 +235,7 @@ void HomeActivity::render(RenderLock&&) {
menuIcons.insert(menuIcons.begin() + 2, Library);
}
if (metrics.homeContinueReadingInMenu) {
if (metrics.homeContinueReadingInMenu && !recentBooks.empty()) {
// Insert Continue Reading at the top if enabled in theme
menuItems.insert(menuItems.begin(), tr(STR_CONTINUE_READING));
menuIcons.insert(menuIcons.begin(), Book);
+5 -14
View File
@@ -30,7 +30,6 @@
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700;
// pages per minute, first item is 1 to prevent division by zero if accessed
constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
@@ -185,7 +184,7 @@ void EpubReaderActivity::loop() {
return;
}
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -203,7 +202,7 @@ void EpubReaderActivity::loop() {
return;
}
const bool longPress = !fromTilt && mappedInput.getHeldTime() > skipChapterMs;
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
// Don't skip chapter after screenshot
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
@@ -583,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(),
@@ -746,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) {
@@ -817,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();
@@ -32,5 +32,4 @@ class EpubReaderChapterSelectionActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
};
@@ -19,7 +19,6 @@ class EpubReaderFootnotesActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
private:
const std::vector<FootnoteEntry>& footnotes;
@@ -32,7 +32,6 @@ class EpubReaderMenuActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
private:
struct MenuItem {
@@ -15,7 +15,6 @@ class EpubReaderPercentSelectionActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
private:
// Current percent value (0-100) shown on the slider.
@@ -41,7 +41,6 @@ class KOReaderSyncActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
bool isReaderActivity() const override { return true; }
private:
enum State {
@@ -14,7 +14,6 @@ class QrDisplayActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
private:
std::string textPayload;
+1
View File
@@ -10,6 +10,7 @@
namespace ReaderUtils {
constexpr unsigned long GO_HOME_MS = 1000;
constexpr unsigned long SKIP_HOLD_MS = 700;
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
switch (orientation) {
+11 -5
View File
@@ -19,7 +19,7 @@ namespace {
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
// Cache file magic and version
constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI"
constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes
constexpr uint8_t CACHE_VERSION = 3; // Increment when cache format changes
} // namespace
void TxtReaderActivity::onEnter() {
@@ -71,7 +71,7 @@ void TxtReaderActivity::loop() {
return;
}
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -223,8 +223,14 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
// Track position within this source line (in bytes from pos)
size_t lineBytePos = 0;
// Word wrap if needed
while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage) {
// Emit at least one visual line for each source line (including blank lines),
// then continue with wrapping when needed.
do {
if (line.empty()) {
outLines.emplace_back();
break;
}
int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str());
if (lineWidth <= viewportWidth) {
@@ -264,7 +270,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
}
lineBytePos += skipChars;
line = line.substr(skipChars);
}
} while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage);
// Determine how much of the source buffer we consumed
if (line.empty()) {
+85 -44
View File
@@ -10,22 +10,19 @@
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <HalTiltSensor.h>
#include <I18n.h>
#include <algorithm>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "XtcReaderChapterSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr unsigned long skipPageMs = 700;
constexpr unsigned long goHomeMs = 1000;
} // namespace
void XtcReaderActivity::onEnter() {
Activity::onEnter();
@@ -70,34 +67,19 @@ void XtcReaderActivity::loop() {
}
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(xtc ? xtc->getPath() : "");
return;
}
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
onGoHome();
return;
}
// When long-press chapter skip is disabled, turn pages on press instead of release.
const bool usePressForPageTurn = SETTINGS.longPressButtonBehavior == SETTINGS.OFF;
const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward();
const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack();
const bool prevTriggered =
tiltPrev || (usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left))
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)));
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power);
const bool nextTriggered =
tiltNext || (usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) ||
powerPageTurn || mappedInput.wasPressed(MappedInputManager::Button::Right))
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
powerPageTurn || mappedInput.wasReleased(MappedInputManager::Button::Right)));
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -113,9 +95,8 @@ void XtcReaderActivity::loop() {
return;
}
const bool fromTilt = tiltPrev || tiltNext;
const bool skipPages =
!fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && mappedInput.getHeldTime() > skipPageMs;
const bool skipPages = !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP &&
mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
const int skipAmount = skipPages ? 10 : 1;
if (prevTriggered) {
@@ -152,6 +133,76 @@ void XtcReaderActivity::render(RenderLock&&) {
saveProgress();
}
XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo() const {
const int bookPageCount = static_cast<int>(xtc->getPageCount());
const int bookPage = static_cast<int>(currentPage) + 1;
std::string title =
SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE ? xtc->getTitle() : "";
if (!xtc->hasChapters()) {
return StatusBarInfo{bookPage, bookPageCount, std::move(title)};
}
const auto& chapters = xtc->getChapters();
const auto chapterIt = std::find_if(chapters.begin(), chapters.end(), [this](const xtc::ChapterInfo& chapter) {
return currentPage >= chapter.startPage && currentPage <= chapter.endPage;
});
if (chapterIt == chapters.end() || chapterIt->endPage < chapterIt->startPage) {
return StatusBarInfo{bookPage, bookPageCount, std::move(title)};
}
if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) {
title = chapterIt->name.empty() ? tr(STR_UNNAMED) : chapterIt->name;
}
return StatusBarInfo{static_cast<int>(currentPage - chapterIt->startPage) + 1,
static_cast<int>(chapterIt->endPage - chapterIt->startPage) + 1, std::move(title)};
}
void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition position) const {
const bool drawBottom = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_BOTTOM &&
position == StatusBarOverlayPosition::Bottom;
const bool drawTop = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP &&
position == StatusBarOverlayPosition::Top;
if (!drawBottom && !drawTop) {
return;
}
const int statusBarHeight = UITheme::getInstance().getStatusBarHeight();
if (statusBarHeight <= 0) {
return;
}
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
int clearY;
int paddingBottom = 0;
if (position == StatusBarOverlayPosition::Bottom) {
clearY = renderer.getScreenHeight() - orientedMarginBottom - statusBarHeight - 4;
if (clearY < 0) {
clearY = 0;
}
} else {
clearY = orientedMarginTop;
paddingBottom = renderer.getScreenHeight() - statusBarHeight - orientedMarginBottom - orientedMarginTop - 4;
}
const int clearHeight = position == StatusBarOverlayPosition::Bottom
? renderer.getScreenHeight() - orientedMarginBottom - clearY
: statusBarHeight + 4;
if (clearHeight > 0) {
renderer.fillRect(0, clearY, renderer.getScreenWidth(), clearHeight, false);
}
const int pageCount = static_cast<int>(xtc->getPageCount());
const int displayPage = static_cast<int>(currentPage) + 1;
const float progress = pageCount > 0 ? (static_cast<float>(displayPage) * 100.0f) / pageCount : 0.0f;
const auto pageInfo = getStatusBarInfo();
GUI.drawStatusBar(renderer, progress, pageInfo.currentPage, pageInfo.pageCount, pageInfo.title, paddingBottom);
}
void XtcReaderActivity::renderPage() {
const uint16_t pageWidth = xtc->getPageWidth();
const uint16_t pageHeight = xtc->getPageHeight();
@@ -242,14 +293,7 @@ void XtcReaderActivity::renderPage() {
}
}
// Display BW with conditional refresh based on pagesUntilFullRefresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
}
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
@@ -319,17 +363,14 @@ void XtcReaderActivity::renderPage() {
free(pageBuffer);
// XTC pages already have status bar pre-rendered, no need to add our own
// Display with appropriate refresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
if (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP) {
renderStatusBarOverlay(StatusBarOverlayPosition::Top);
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
renderStatusBarOverlay(StatusBarOverlayPosition::Bottom);
}
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);
}
+12
View File
@@ -9,6 +9,9 @@
#include <Xtc.h>
#include <string>
#include <utility>
#include "activities/Activity.h"
class XtcReaderActivity final : public Activity {
@@ -17,7 +20,16 @@ class XtcReaderActivity final : public Activity {
uint32_t currentPage = 0;
int pagesUntilFullRefresh = 0;
enum class StatusBarOverlayPosition { Bottom, Top };
struct StatusBarInfo {
int currentPage;
int pageCount;
std::string title;
};
void renderPage();
void renderStatusBarOverlay(StatusBarOverlayPosition position) const;
StatusBarInfo getStatusBarInfo() const;
void saveProgress() const;
void loadProgress();
@@ -23,5 +23,4 @@ class XtcReaderChapterSelectionActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
};
@@ -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)
@@ -11,13 +11,14 @@
#include "fontIds.h"
namespace {
constexpr int MENU_ITEMS = 6;
constexpr int MENU_ITEMS = 7;
const StrId menuNames[MENU_ITEMS] = {StrId::STR_CHAPTER_PAGE_COUNT,
StrId::STR_BOOK_PROGRESS_PERCENTAGE,
StrId::STR_PROGRESS_BAR,
StrId::STR_PROGRESS_BAR_THICKNESS,
StrId::STR_TITLE,
StrId::STR_BATTERY};
StrId::STR_BATTERY,
StrId::STR_XTC_STATUS_BAR};
constexpr int PROGRESS_BAR_ITEMS = 3;
const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
@@ -28,6 +29,9 @@ const StrId progressBarThicknessNames[PROGRESS_BAR_THICKNESS_ITEMS] = {
constexpr int TITLE_ITEMS = 3;
const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
constexpr int XTC_STATUS_BAR_ITEMS = 3;
const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP};
const int widthMargin = 10;
const int verticalPreviewPadding = 50;
const int verticalPreviewTextPadding = 40;
@@ -51,6 +55,10 @@ void StatusBarSettingsActivity::onEnter() {
SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE;
}
if (SETTINGS.xtcStatusBarMode >= XTC_STATUS_BAR_ITEMS) {
SETTINGS.xtcStatusBarMode = CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_HIDE;
}
requestUpdate();
}
@@ -110,6 +118,9 @@ void StatusBarSettingsActivity::handleSelection() {
} else if (selectedIndex == 5) {
// Show Battery
SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2;
} else if (selectedIndex == 6) {
// XTC Status Bar
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
}
SETTINGS.saveToFile();
}
@@ -143,6 +154,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
return I18N.get(titleNames[SETTINGS.statusBarTitle]);
} else if (index == 5) {
return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE);
} else if (index == 6) {
return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]);
} else {
return tr(STR_HIDE);
}
+113 -3
View File
@@ -1,19 +1,67 @@
#include "BmpViewerActivity.h"
#include <Bitmap.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <algorithm>
#include "CrossPointSettings.h"
#include "components/UITheme.h"
#include "fontIds.h"
BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path)
: Activity("BmpViewer", renderer, mappedInput), filePath(std::move(path)) {}
void BmpViewerActivity::loadSiblingImages() {
siblingImages.clear();
currentImageIndex = -1;
if (filePath.empty()) return;
std::string dirPath = FsHelpers::extractFolderPath(filePath);
size_t lastSlash = filePath.find_last_of('/');
std::string fileName = (lastSlash != std::string::npos) ? filePath.substr(lastSlash + 1) : filePath;
auto dir = Storage.open(dirPath.c_str());
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
return;
}
char name[500];
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
if (!file.isDirectory()) {
file.getName(name, sizeof(name));
if (name[0] != '.') {
std::string fname(name);
if (fname.length() >= 4 && fname.substr(fname.length() - 4) == ".bmp") {
siblingImages.push_back(fname);
}
}
}
file.close();
}
dir.close();
FsHelpers::sortFileList(siblingImages);
for (size_t i = 0; i < siblingImages.size(); ++i) {
if (siblingImages[i] == fileName) {
currentImageIndex = static_cast<int>(i);
break;
}
}
}
void BmpViewerActivity::onEnter() {
Activity::onEnter();
// Removed the redundant initial renderer.clearScreen()
if (siblingImages.empty() && !filePath.empty()) {
loadSiblingImages();
}
FsFile file;
@@ -49,7 +97,8 @@ void BmpViewerActivity::onEnter() {
}
// 4. Prepare Rendering
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", "");
GUI.fillPopupProgress(renderer, popupRect, 50);
renderer.clearScreen();
@@ -61,7 +110,7 @@ void BmpViewerActivity::onEnter() {
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Single pass for non-grayscale images
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
// Handle file parsing error
@@ -89,6 +138,39 @@ void BmpViewerActivity::onExit() {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
void BmpViewerActivity::doSetSleepCover() {
GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
bool success = false;
FsFile inFile, outFile;
if (Storage.openFileForRead("BMP", filePath, inFile)) {
if (Storage.openFileForWrite("BMP", "/sleep.bmp", outFile)) {
char buffer[2048];
int bytesRead;
success = true;
while ((bytesRead = inFile.read(buffer, sizeof(buffer))) > 0) {
if (outFile.write(buffer, bytesRead) != bytesRead) {
success = false;
break;
}
}
outFile.close();
}
inFile.close();
}
if (success) {
SETTINGS.sleepScreen = CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM;
SETTINGS.saveToFile();
GUI.drawPopup(renderer, tr(STR_DONE));
} else {
GUI.drawPopup(renderer, tr(STR_FAILED_LOWER));
}
delay(1000);
onEnter();
}
void BmpViewerActivity::loop() {
// Keep CPU awake/polling so 1st click works
Activity::loop();
@@ -97,4 +179,32 @@ void BmpViewerActivity::loop() {
activityManager.goToFileBrowser(filePath);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
doSetSleepCover();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
if (siblingImages.size() > 1 && currentImageIndex > 0) {
currentImageIndex--;
std::string dirPath = FsHelpers::extractFolderPath(filePath);
if (dirPath.back() != '/') dirPath += "/";
filePath = dirPath + siblingImages[currentImageIndex];
onEnter();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
if (siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1) {
currentImageIndex++;
std::string dirPath = FsHelpers::extractFolderPath(filePath);
if (dirPath.back() != '/') dirPath += "/";
filePath = dirPath + siblingImages[currentImageIndex];
onEnter();
}
return;
}
}
+5
View File
@@ -15,5 +15,10 @@ class BmpViewerActivity final : public Activity {
void loop() override;
private:
void loadSiblingImages();
void doSetSleepCover();
std::string filePath;
std::vector<std::string> siblingImages;
int currentImageIndex = -1;
};
-4
View File
@@ -13,10 +13,6 @@
#include "components/themes/lyra/LyraTheme.h"
#include "components/themes/roundedraff/RoundedRaffTheme.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
} // namespace
UITheme UITheme::instance;
UITheme::UITheme() {
+47 -43
View File
@@ -20,37 +20,6 @@ constexpr int homeMenuMargin = 20;
constexpr int homeMarginTop = 30;
constexpr int subtitleY = 738;
// Helper: draw battery icon at given position
void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) {
// Draw battery outline (shared code)
BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight);
const bool charging = gpio.isUsbConnected();
// The +1 is to round up, so that we always fill at least one pixel
const int maxFillWidth = battWidth - 5;
const int fillHeight = rectHeight - 4;
if (maxFillWidth <= 0 || fillHeight <= 0) {
return;
}
int filledWidth = percentage * maxFillWidth / 100 + 1;
if (filledWidth > maxFillWidth) {
filledWidth = maxFillWidth;
}
// When charging, ensure minimum fill so lightning bolt is fully visible
constexpr int minFillForBolt = 8;
if (charging && filledWidth < minFillForBolt) {
filledWidth = std::min(minFillForBolt, maxFillWidth);
}
renderer.fillRect(x + 2, y + 2, filledWidth, fillHeight);
// Draw lightning bolt when charging (white/inverted on black fill for visibility)
if (charging) {
BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2);
}
}
} // namespace
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
@@ -79,6 +48,33 @@ void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX,
renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false);
}
void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const {
const bool charging = gpio.isUsbConnected();
const int maxFillWidth = rect.width - 5;
const int fillHeight = rect.height - 4;
if (maxFillWidth <= 0 || fillHeight <= 0) {
return;
}
// +1 to round up so we always fill at least one pixel
int filledWidth = percentage * maxFillWidth / 100 + 1;
if (filledWidth > maxFillWidth) {
filledWidth = maxFillWidth;
}
// When charging, ensure minimum fill so lightning bolt is fully visible
constexpr int minFillForBolt = 8;
if (charging && filledWidth < minFillForBolt) {
filledWidth = std::min(minFillForBolt, maxFillWidth);
}
renderer.fillRect(rect.x + 2, rect.y + 2, filledWidth, fillHeight);
if (charging) {
drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2);
}
}
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode)
const uint16_t percentage = powerManager.getBatteryPercentage();
@@ -86,11 +82,12 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + BaseMetrics::values.batteryWidth,
rect.y, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + rect.width, rect.y, percentageText.c_str());
}
drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage);
const Rect iconRect{rect.x, y, rect.width, rect.height};
drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height);
fillBatteryIcon(renderer, iconRect, percentage);
}
void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
@@ -102,16 +99,12 @@ void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
// Clear the area where we're going to draw the text to prevent ghosting
const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID);
renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false);
// Draw text to the left of the icon
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y,
percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
}
// Icon is already at correct position from rect.x
drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage);
const Rect iconRect{rect.x, y, rect.width, rect.height};
drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height);
fillBatteryIcon(renderer, iconRect, percentage);
}
void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current,
@@ -238,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;
@@ -286,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);
+11 -11
View File
@@ -125,11 +125,12 @@ class BaseTheme {
virtual ~BaseTheme() = default;
// Component drawing methods
virtual void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const;
virtual void drawBatteryLeft(const GfxRenderer& renderer, Rect rect,
bool showPercentage = true) const; // Left aligned (reader mode)
virtual void drawBatteryRight(const GfxRenderer& renderer, Rect rect,
bool showPercentage = true) const; // Right aligned (UI headers)
void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const;
void drawBatteryLeft(const GfxRenderer& renderer, Rect rect,
bool showPercentage = true) const; // Left aligned (reader mode)
void drawBatteryRight(const GfxRenderer& renderer, Rect rect,
bool showPercentage = true) const; // Right aligned (UI headers)
virtual void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const;
virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const;
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const;
@@ -137,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,
@@ -153,10 +154,9 @@ class BaseTheme {
const std::function<UIIcon(int index)>& rowIcon) const;
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const;
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
const int pageCount, std::string title, const int paddingBottom = 0,
const int textYOffset = 0) const;
virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
std::string title, const int paddingBottom = 0, const int textYOffset = 0) const;
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
int contentStartX = 0, int contentWidth = 0) const;
virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected,
+27 -52
View File
@@ -41,30 +41,6 @@ constexpr int listIconSize = 24;
constexpr int mainMenuColumns = 2;
int coverWidth = 0;
void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight,
uint16_t percentage) {
BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight);
const bool charging = gpio.isUsbConnected();
if (charging) {
// Draw solid fill when charging so lightning bolt is visible
renderer.fillRect(x + 2, y + 2, battWidth - 5, rectHeight - 4);
BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2);
} else {
// Draw bars when not charging
if (percentage > 10) {
renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4);
}
if (percentage > 40) {
renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4);
}
if (percentage > 70) {
renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4);
}
}
}
const uint8_t* iconForName(UIIcon icon, int size) {
if (size == 24) {
switch (icon) {
@@ -107,35 +83,24 @@ const uint8_t* iconForName(UIIcon icon, int size) {
}
} // namespace
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode)
const uint16_t percentage = powerManager.getBatteryPercentage();
void LyraTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const {
const bool charging = gpio.isUsbConnected();
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + LyraMetrics::values.batteryWidth,
rect.y, percentageText.c_str());
if (charging) {
// Solid fill when charging so lightning bolt is visible
renderer.fillRect(rect.x + 2, rect.y + 2, rect.width - 5, rect.height - 4);
drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2);
} else {
if (percentage > 10) {
renderer.fillRect(rect.x + 2, rect.y + 2, 3, rect.height - 4);
}
if (percentage > 40) {
renderer.fillRect(rect.x + 6, rect.y + 2, 3, rect.height - 4);
}
if (percentage > 70) {
renderer.fillRect(rect.x + 10, rect.y + 2, 3, rect.height - 4);
}
}
drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage);
}
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers)
const uint16_t percentage = powerManager.getBatteryPercentage();
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
// Clear the area where we're going to draw the text to prevent ghosting
const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID);
renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false);
// Draw text to the left of the icon
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y,
percentageText.c_str());
}
drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage);
}
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const {
@@ -243,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;
@@ -302,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);
+2 -4
View File
@@ -49,9 +49,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
class LyraTheme : public BaseTheme {
public:
// Component drawing methods
// void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override;
void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override;
void drawBatteryRight(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override;
void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const override;
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override;
void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
const char* rightLabel = nullptr) const override;
@@ -61,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;
@@ -1,7 +1,6 @@
#include "RoundedRaffTheme.h"
#include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <I18n.h>
@@ -22,7 +21,6 @@ constexpr int kBottomRadius = 15;
constexpr int kRowRadius = 20;
constexpr int kInteractiveInsetX = 20;
constexpr int kSelectableRowGap = 6;
constexpr int batteryPercentSpacing = 4;
constexpr int kTitleFontId = UI_12_FONT_ID; // Requested main title size: 12px
constexpr int kSubtitleFontId = SMALL_FONT_ID; // Requested subtitle size: 8px
constexpr int kGuideFontId = SMALL_FONT_ID; // Closest available to requested 6px
@@ -46,42 +44,6 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa
renderer.fillRect(barX, thumbY, barW, thumbH);
}
void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) {
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rectHeight - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5);
// The +1 is to round up, so that we always fill at least one pixel.
int filledWidth = percentage * (battWidth - 5) / 100 + 1;
if (filledWidth > battWidth - 5) {
filledWidth = battWidth - 5; // Ensure we don't overflow.
}
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
}
void drawBatteryRightStable(const GfxRenderer& renderer, Rect iconRect, uint16_t percentage, bool showPercentage) {
// Match BaseTheme::drawBatteryRight layout, but use a stable percentage value for this render.
const int iconY = iconRect.y + 6;
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, iconRect.x - textWidth - batteryPercentSpacing, iconRect.y,
percentageText.c_str());
}
drawBatteryIcon(renderer, iconRect.x, iconY, RoundedRaffMetrics::values.batteryWidth, iconRect.height, percentage);
}
std::string sanitizeButtonLabel(std::string label) {
// Remove common directional prefixes/symbols (e.g. "<< Home", unsupported icon glyphs).
while (!label.empty() && !std::isalnum(static_cast<unsigned char>(label[0]))) {
@@ -110,28 +72,27 @@ void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
const uint16_t percentage = powerManager.getBatteryPercentage();
const int batteryIconX = rect.x + rect.width - sidePadding - RoundedRaffMetrics::values.batteryWidth;
// Reserve space for the widest possible percentage text to avoid title/battery overlap
int batteryGroupLeftX = batteryIconX;
if (showBatteryPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
batteryGroupLeftX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()) + batteryPercentSpacing;
// Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% ->
// 99%).
// Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% -> 99%)
const int maxTextWidth = renderer.getTextWidth(SMALL_FONT_ID, "100%");
batteryGroupLeftX -= maxTextWidth + batteryPercentSpacing;
const int clearW = maxTextWidth + batteryPercentSpacing + RoundedRaffMetrics::values.batteryWidth;
const int clearH = std::max(renderer.getTextHeight(SMALL_FONT_ID), RoundedRaffMetrics::values.batteryHeight + 8);
renderer.fillRect(batteryIconX - maxTextWidth - batteryPercentSpacing, rect.y + 14, clearW, clearH, false);
}
const int maxTextWidth = std::max(0, batteryGroupLeftX - 20 - titleX);
auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTextWidth, EpdFontFamily::BOLD);
const int maxTitleWidth = std::max(0, batteryGroupLeftX - 20 - titleX);
auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTitleWidth, EpdFontFamily::BOLD);
renderer.drawText(kTitleFontId, titleX, titleY, headerTitle.c_str(), true, EpdFontFamily::BOLD);
drawBatteryRightStable(renderer,
Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth,
RoundedRaffMetrics::values.batteryHeight},
percentage, showBatteryPercentage);
drawBatteryRight(renderer,
Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth,
RoundedRaffMetrics::values.batteryHeight},
showBatteryPercentage);
}
void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
@@ -286,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");
+20 -2
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");
}
@@ -381,7 +389,9 @@ void loop() {
}
static bool screenshotButtonsReleased = true;
static bool screenshotComboActive = false;
if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) {
screenshotComboActive = true;
if (screenshotButtonsReleased) {
screenshotButtonsReleased = false;
{
@@ -390,8 +400,16 @@ void loop() {
}
}
return;
} else {
}
if (screenshotComboActive) {
if (gpio.isPressed(HalGPIO::BTN_POWER)) return;
if (gpio.wasReleased(HalGPIO::BTN_POWER)) {
screenshotButtonsReleased = true;
screenshotComboActive = false;
return;
}
screenshotButtonsReleased = true;
screenshotComboActive = false;
}
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
+362 -5
View File
@@ -11,10 +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"
@@ -163,11 +168,22 @@ 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(); });
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
// Wi-Fi credential endpoints
server->on("/api/wifi", HTTP_GET, [this] { handleGetWifiNetworks(); });
server->on("/api/wifi", HTTP_POST, [this] { handlePostWifiNetwork(); });
server->on("/api/wifi/delete", HTTP_POST, [this] { handleDeleteWifiNetwork(); });
server->onNotFound([this] { handleNotFound(); });
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
@@ -1095,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", "");
@@ -1130,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;
}
@@ -1191,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) {
@@ -1209,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) {
@@ -1368,6 +1395,140 @@ void CrossPointWebServer::handleDeleteOpdsServer() {
server->send(200, "text/plain", "OK");
}
// ---- Wi-Fi Credentials API ----
void CrossPointWebServer::handleGetWifiNetworks() const {
const auto& credentials = WIFI_STORE.getCredentials();
const std::string& lastConnectedSsid = WIFI_STORE.getLastConnectedSsid();
// Stream JSON array incrementally to avoid allocating the full response in memory
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
char output[320];
constexpr size_t outputSize = sizeof(output);
JsonDocument doc;
for (size_t i = 0; i < credentials.size(); i++) {
doc.clear();
doc["index"] = i;
doc["ssid"] = credentials[i].ssid;
// Never expose Wi-Fi passwords over the API — only indicate whether one is set
doc["hasPassword"] = !credentials[i].password.empty();
doc["isLastConnected"] = credentials[i].ssid == lastConnectedSsid;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) continue;
if (i > 0) server->sendContent(",");
server->sendContent(output);
}
server->sendContent("]");
server->sendContent("");
LOG_DBG("WEB", "Served Wi-Fi credentials API (%zu network(s))", credentials.size());
}
void CrossPointWebServer::handlePostWifiNetwork() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
std::string ssid = doc["ssid"] | std::string("");
if (ssid.empty()) {
server->send(400, "text/plain", "SSID is required");
return;
}
// The password field is optional in the JSON payload. When absent (vs. present but empty),
// preserve the existing password for updates. Empty passwords are valid for open networks.
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
std::string password = doc["password"] | std::string("");
if (doc["index"].is<int>()) {
int idx = doc["index"].as<int>();
const auto& credentials = WIFI_STORE.getCredentials();
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
server->send(400, "text/plain", "Invalid network index");
return;
}
const std::string oldSsid = credentials[static_cast<size_t>(idx)].ssid;
if (!hasPasswordField) {
password = credentials[static_cast<size_t>(idx)].password;
}
bool ok = true;
if (oldSsid != ssid) {
ok = WIFI_STORE.removeCredential(oldSsid) && WIFI_STORE.addCredential(ssid, password);
} else {
ok = WIFI_STORE.addCredential(ssid, password);
}
if (!ok) {
server->send(400, "text/plain", "Failed to update Wi-Fi network");
return;
}
LOG_DBG("WEB", "Updated Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
} else {
if (!WIFI_STORE.addCredential(ssid, password)) {
server->send(400, "text/plain", "Cannot add network (limit reached)");
return;
}
LOG_DBG("WEB", "Added Wi-Fi network: %s", ssid.c_str());
}
server->send(200, "text/plain", "OK");
}
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
void CrossPointWebServer::handleDeleteWifiNetwork() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
if (!doc["index"].is<int>()) {
server->send(400, "text/plain", "Missing index");
return;
}
int idx = doc["index"].as<int>();
const auto& credentials = WIFI_STORE.getCredentials();
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
server->send(400, "text/plain", "Invalid network index");
return;
}
const std::string ssid = credentials[static_cast<size_t>(idx)].ssid;
if (!WIFI_STORE.removeCredential(ssid)) {
server->send(400, "text/plain", "Failed to delete Wi-Fi network");
return;
}
LOG_DBG("WEB", "Deleted Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
server->send(200, "text/plain", "OK");
}
// WebSocket callback trampoline
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (wsInstance) {
@@ -1554,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);
}
}
+27
View File
@@ -108,8 +108,35 @@ 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();
void handleDeleteOpdsServer();
// Wi-Fi credential handlers
void handleGetWifiNetworks() const;
void handlePostWifiNetwork();
void handleDeleteWifiNetwork();
};
+3 -2
View File
@@ -44,7 +44,7 @@
}
h2 {
color: var(--title-color);
margin-top: 0;
margin: 0;
}
.card {
background: var(--card-bg);
@@ -63,7 +63,7 @@
}
.page-header-left {
display: flex;
align-items: baseline;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
@@ -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">
+116
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>
@@ -299,6 +300,7 @@
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
</div>
<div id="wifi-container"></div>
<div id="opds-container"></div>
<div class="card">
@@ -480,6 +482,119 @@
loadSettings();
// --- Wi-Fi Network Management ---
// Renders an editable list of saved Wi-Fi networks using /api/wifi endpoints.
// Password fields are never pre-filled; when left blank during edit, existing
// passwords remain unchanged server-side.
let wifiNetworks = [];
function renderWifiNetwork(net, idx) {
const isNew = idx === -1;
const id = isNew ? 'new' : idx;
const lastConnected = net.isLastConnected
? '<div style="margin-top:8px;color:var(--label-color);font-size:0.9em;">Last connected network</div>'
: '';
return '<div class="opds-server" id="wifi-' + id + '">' +
'<div class="setting-row">' +
'<span class="setting-name">SSID</span>' +
'<span class="setting-control"><input type="text" id="wifi-ssid-' + id + '" value="' + escapeHtml(net.ssid || '') + '"></span>' +
'</div>' +
'<div class="setting-row">' +
'<span class="setting-name">Password</span>' +
'<span class="setting-control"><input type="password" id="wifi-pass-' + id + '" placeholder="' + (net.hasPassword ? '(unchanged)' : '') + '"></span>' +
'</div>' +
lastConnected +
'<div class="opds-actions">' +
'<button class="btn-small btn-save-server" onclick="saveWifiNetwork(' + idx + ')">Save</button>' +
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteWifiNetwork(' + idx + ')">Delete</button>') +
'</div>' +
'</div>';
}
function renderWifiSection() {
const container = document.getElementById('wifi-container');
let html = '<div class="card"><h2>Wi-Fi Networks</h2>';
if (wifiNetworks.length === 0) {
html += '<p style="color:var(--label-color);text-align:center;">No Wi-Fi networks saved</p>';
} else {
wifiNetworks.forEach(function(net, idx) {
html += renderWifiNetwork(net, idx);
});
}
html += '<div style="margin-top:12px;text-align:center;">' +
'<button class="btn-small btn-add" onclick="addWifiNetwork()">+ Add Network</button>' +
'</div></div>';
container.innerHTML = html;
}
async function loadWifiNetworks() {
try {
const resp = await fetch('/api/wifi');
if (!resp.ok) throw new Error('Failed to load');
wifiNetworks = await resp.json();
renderWifiSection();
} catch (e) {
console.error('Wi-Fi load error:', e);
}
}
function addWifiNetwork() {
const container = document.getElementById('wifi-container');
const card = container.querySelector('.card');
const addBtn = card.querySelector('.btn-add').parentElement;
// Prevent multiple unsaved new-network forms at once (idx -1 -> id "new")
if (document.getElementById('wifi-new')) return;
addBtn.insertAdjacentHTML('beforebegin', renderWifiNetwork({ssid:'',hasPassword:false,isLastConnected:false}, -1));
}
async function saveWifiNetwork(idx) {
const id = idx === -1 ? 'new' : idx;
const ssid = document.getElementById('wifi-ssid-' + id).value.trim();
if (!ssid) {
showMessage('SSID is required.', true);
return;
}
const data = { ssid: ssid };
// Only include password when the user actually typed something; omitting it
// tells the server to preserve an existing password.
const pass = document.getElementById('wifi-pass-' + id).value;
if (pass) data.password = pass;
if (idx >= 0) data.index = idx;
try {
const resp = await fetch('/api/wifi', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('Wi-Fi network saved!', false);
await loadWifiNetworks();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
async function deleteWifiNetwork(idx) {
if (!confirm('Delete this Wi-Fi network?')) return;
try {
const resp = await fetch('/api/wifi/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({index: idx})
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('Wi-Fi network deleted', false);
await loadWifiNetworks();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
// --- OPDS Server Management ---
// Dynamically renders an editable list of OPDS servers, communicating with the
// /api/opds REST endpoints. Password fields are never pre-filled for security;
@@ -594,6 +709,7 @@
}
}
loadWifiNetworks();
loadOpdsServers();
</script>
</body>