feat: add SD card font support with on-device download and web management
Add a complete SD card font subsystem that enables users to install and use custom fonts beyond the three built-in families. This combines the back-end firmware support (#1327) with the font configuration, build pipeline, CI distribution, and user-facing management UI (#1392). Core font system: - Custom .cpfont binary format (v4) with multi-style support (regular, bold, italic, bold-italic) packed into a single file per size - On-demand glyph loading from SD card with two-pass prewarm rendering to bulk-read glyphs per page, achieving near-flash performance for Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower) - Persistent advance cache for layout measurement without SD I/O - Overflow ring buffer for glyph cache misses during rendering - Memory-conscious design: only advance tables kept in RAM; glyph bitmaps, kern tables, and ligatures loaded on demand from SD Font management: - On-device WiFi download from GitHub Releases with manifest-based discovery, install/update detection, and progress UI - Web interface font upload, listing, and deletion via /fonts page - Manual SD card copy to /fonts/ or /.fonts/ directories - Font selection integrated into Settings > Reader > Font Family Build pipeline: - Declarative YAML config (sd-fonts.yaml) as single source of truth for the 17-family font library (serif, sans, mono, accessibility) - Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with FreeType rasterization, class-based kerning, and ligature extraction - Parallel build orchestrator with variable font instance extraction - CI workflow publishing versioned + stable releases to a dedicated crosspoint-fonts repository with auto-incrementing revision tags - Centralized version constants (cpfont_version.py) shared across build tooling and CI, with firmware headers as manual sync points Additional fixes: - CJK characters no longer get hyphens inserted at line breaks - Advance table eliminates 30+ second stalls during CJK section indexing for paragraphs with >512 unique codepoints Closes #930 Co-authored-by: Zach Nelson <zach@zdnelson.com> Co-authored-by: Justin <itsthisjustin@users.noreply.github.com> Co-authored-by: jpirnay <jens@pirnay.com> Co-authored-by: mcrosson <kemonine@kemonine.info>
This commit is contained in:
committed by
Zach Nelson
co-authored by
Zach Nelson
Justin
jpirnay
mcrosson
parent
29fd29f537
commit
7993b2bb97
@@ -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."
|
||||||
@@ -15,3 +15,7 @@ build
|
|||||||
.history/
|
.history/
|
||||||
/.venv
|
/.venv
|
||||||
*.local*
|
*.local*
|
||||||
|
*.cpfont
|
||||||
|
lib/EpdFont/scripts/downloaded_fonts/
|
||||||
|
lib/EpdFont/scripts/instanced_fonts/
|
||||||
|
lib/EpdFont/scripts/output/
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# SD Card Fonts
|
||||||
|
|
||||||
|
CrossPoint supports loading additional fonts from the SD card, including fonts
|
||||||
|
with extended Unicode coverage (CJK, Cyrillic, Greek, etc.).
|
||||||
|
|
||||||
|
## Installing Fonts
|
||||||
|
|
||||||
|
There are three ways to install fonts:
|
||||||
|
|
||||||
|
### Option 1: Download from device (recommended)
|
||||||
|
|
||||||
|
1. Connect your CrossPoint reader to WiFi
|
||||||
|
2. Go to **Settings > System > Download Fonts**
|
||||||
|
3. Browse available font families and tap to download
|
||||||
|
4. Downloaded fonts appear immediately in **Settings > Reader > Font Family**
|
||||||
|
|
||||||
|
### Option 2: Upload via web browser
|
||||||
|
|
||||||
|
1. Connect your CrossPoint reader to WiFi
|
||||||
|
2. Open the web interface in your browser (shown on the WiFi screen)
|
||||||
|
3. Navigate to the **Fonts** tab
|
||||||
|
4. Upload `.cpfont` files using the upload form
|
||||||
|
|
||||||
|
### Option 3: Manual SD card copy
|
||||||
|
|
||||||
|
1. Download font files from the
|
||||||
|
[Releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases/tag/sd-fonts)
|
||||||
|
2. Copy font family folders to `/.crosspoint/fonts/` on your SD card:
|
||||||
|
|
||||||
|
SD Card Root/
|
||||||
|
└── .crosspoint/
|
||||||
|
└── fonts/
|
||||||
|
├── Bookerly-SD/
|
||||||
|
│ ├── Bookerly-SD_12.cpfont
|
||||||
|
│ ├── Bookerly-SD_14.cpfont
|
||||||
|
│ ├── Bookerly-SD_16.cpfont
|
||||||
|
│ └── Bookerly-SD_18.cpfont
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
3. Insert the SD card and power on your CrossPoint reader
|
||||||
|
|
||||||
|
## Available Pre-Built Fonts
|
||||||
|
|
||||||
|
| Font | Best For | Languages |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Bookerly-SD | General reading | English, Western European |
|
||||||
|
| NotoSansExtended | Multi-script reading | European, Greek, Cyrillic, Georgian, Armenian, Ethiopic |
|
||||||
|
| NotoSansCJK | Chinese/Japanese/Korean | CJK + ASCII |
|
||||||
|
|
||||||
|
## Converting Custom Fonts
|
||||||
|
|
||||||
|
To convert your own TrueType/OpenType fonts:
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
pip install freetype-py fonttools
|
||||||
|
|
||||||
|
### Single font (one style)
|
||||||
|
|
||||||
|
python3 lib/EpdFont/scripts/fontconvert_sdcard.py \
|
||||||
|
MyFont-Regular.ttf \
|
||||||
|
--intervals latin-ext \
|
||||||
|
--sizes 12,14,16,18 \
|
||||||
|
--style regular \
|
||||||
|
--name MyFont \
|
||||||
|
--output-dir ./MyFont/
|
||||||
|
|
||||||
|
### Multi-style font
|
||||||
|
|
||||||
|
python3 lib/EpdFont/scripts/fontconvert_sdcard.py \
|
||||||
|
--regular MyFont-Regular.ttf \
|
||||||
|
--bold MyFont-Bold.ttf \
|
||||||
|
--italic MyFont-Italic.ttf \
|
||||||
|
--bolditalic MyFont-BoldItalic.ttf \
|
||||||
|
--intervals latin-ext \
|
||||||
|
--sizes 12,14,16,18 \
|
||||||
|
--name MyFont \
|
||||||
|
--output-dir ./MyFont/
|
||||||
|
|
||||||
|
### Available Unicode interval presets
|
||||||
|
|
||||||
|
| Preset | Coverage |
|
||||||
|
|--------|----------|
|
||||||
|
| `ascii` | U+0020-U+007E (Basic Latin) |
|
||||||
|
| `latin-ext` | European languages (Latin + Extended-A/B) |
|
||||||
|
| `greek` | Greek + Extended Greek |
|
||||||
|
| `cyrillic` | Cyrillic + Supplement |
|
||||||
|
| `cjk` | CJK Unified Ideographs + Hiragana + Katakana + Fullwidth |
|
||||||
|
| `hangul` | Korean Hangul syllables |
|
||||||
|
| `builtin` | Matches built-in Bookerly coverage exactly |
|
||||||
|
|
||||||
|
Combine presets with commas: `--intervals latin-ext,greek,cyrillic`
|
||||||
|
|
||||||
|
Install custom fonts via WiFi upload or manual SD card copy.
|
||||||
+20
-12
@@ -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 EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
|
||||||
const int count = data->intervalCount;
|
const int count = data->intervalCount;
|
||||||
if (count == 0) return nullptr;
|
if (count == 0 && !data->glyphMissHandler) return nullptr;
|
||||||
|
|
||||||
const EpdUnicodeInterval* intervals = data->intervals;
|
if (count > 0) {
|
||||||
const auto* end = intervals + count;
|
const EpdUnicodeInterval* intervals = data->intervals;
|
||||||
|
const auto* end = intervals + count;
|
||||||
|
|
||||||
// upper_bound: range lookup. Finds the first interval with first > cp, so the
|
// 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
|
// 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.
|
// candidate that could contain cp. Then we verify cp <= candidate.last.
|
||||||
const auto it = std::upper_bound(
|
const auto it = std::upper_bound(
|
||||||
intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
|
intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
|
||||||
|
|
||||||
if (it != intervals) {
|
if (it != intervals) {
|
||||||
const auto& interval = *(it - 1);
|
const auto& interval = *(it - 1);
|
||||||
if (cp <= interval.last) {
|
if (cp <= interval.last) {
|
||||||
return &data->glyph[interval.offset + (cp - interval.first)];
|
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) {
|
if (cp != REPLACEMENT_GLYPH) {
|
||||||
return getGlyph(REPLACEMENT_GLYPH);
|
return getGlyph(REPLACEMENT_GLYPH);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,4 +129,16 @@ typedef struct {
|
|||||||
uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols)
|
uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols)
|
||||||
const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none)
|
const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none)
|
||||||
uint32_t ligaturePairCount; ///< Number of entries in ligaturePairs
|
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;
|
} EpdFontData;
|
||||||
|
|||||||
@@ -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;
|
if (glyphCount == 0) return 0;
|
||||||
|
|
||||||
// Step 2: Compute total buffer size and collect unique groups
|
// Step 2: Compute total buffer size and collect unique groups
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/**
|
||||||
Executable
+331
@@ -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()
|
||||||
@@ -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
|
||||||
Executable
+898
@@ -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()
|
||||||
@@ -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"}
|
||||||
|
|
||||||
@@ -100,6 +100,37 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
|||||||
// Apply fixed transforms before any per-line layout work.
|
// Apply fixed transforms before any per-line layout work.
|
||||||
applyParagraphIndent();
|
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;
|
const int pageWidth = viewportWidth;
|
||||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "Hyphenator.h"
|
#include "Hyphenator.h"
|
||||||
|
|
||||||
|
#include <Utf8.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -256,7 +258,16 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
|
|||||||
std::vector<Hyphenator::BreakInfo> breaks;
|
std::vector<Hyphenator::BreakInfo> breaks;
|
||||||
breaks.reserve(indexes.size());
|
breaks.reserve(indexes.size());
|
||||||
for (const size_t idx : indexes) {
|
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;
|
return breaks;
|
||||||
|
|||||||
@@ -2,18 +2,35 @@
|
|||||||
|
|
||||||
#include <FontDecompressor.h>
|
#include <FontDecompressor.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
#include <SdCardFont.h>
|
||||||
|
|
||||||
#include <cstring>
|
#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::setFontDecompressor(FontDecompressor* d) { fontDecompressor_ = d; }
|
||||||
|
|
||||||
void FontCacheManager::clearCache() {
|
void FontCacheManager::clearCache() {
|
||||||
if (fontDecompressor_) fontDecompressor_->clearCache();
|
if (fontDecompressor_) fontDecompressor_->clearCache();
|
||||||
|
for (auto& [id, font] : sdCardFonts_) {
|
||||||
|
font->clearCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask) {
|
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;
|
if (!fontDecompressor_ || fontMap_.count(fontId) == 0) return;
|
||||||
|
|
||||||
for (uint8_t i = 0; i < 4; i++) {
|
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) {
|
void FontCacheManager::logStats(const char* label) {
|
||||||
if (fontDecompressor_) fontDecompressor_->logStats(label);
|
if (fontDecompressor_) fontDecompressor_->logStats(label);
|
||||||
|
for (auto& [id, font] : sdCardFonts_) {
|
||||||
|
font->logStats(label);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void FontCacheManager::resetStats() {
|
void FontCacheManager::resetStats() {
|
||||||
if (fontDecompressor_) fontDecompressor_->resetStats();
|
if (fontDecompressor_) fontDecompressor_->resetStats();
|
||||||
|
for (auto& [id, font] : sdCardFonts_) {
|
||||||
|
font->resetStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
class FontDecompressor;
|
class FontDecompressor;
|
||||||
|
class SdCardFont;
|
||||||
|
|
||||||
class FontCacheManager {
|
class FontCacheManager {
|
||||||
public:
|
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);
|
void setFontDecompressor(FontDecompressor* d);
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ class FontCacheManager {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
const std::map<int, EpdFontFamily>& fontMap_;
|
const std::map<int, EpdFontFamily>& fontMap_;
|
||||||
|
const std::map<int, SdCardFont*>& sdCardFonts_;
|
||||||
FontDecompressor* fontDecompressor_ = nullptr;
|
FontDecompressor* fontDecompressor_ = nullptr;
|
||||||
|
|
||||||
enum class ScanMode : uint8_t { None, Scanning };
|
enum class ScanMode : uint8_t { None, Scanning };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <FontDecompressor.h>
|
#include <FontDecompressor.h>
|
||||||
#include <HalGPIO.h>
|
#include <HalGPIO.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
#include <SdCardFont.h>
|
||||||
#include <Utf8.h>
|
#include <Utf8.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#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.
|
// must consume it (draw the glyph) before requesting another bitmap.
|
||||||
return fd->getBitmap(fontData, glyph, glyphIndex);
|
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];
|
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() {
|
void GfxRenderer::begin() {
|
||||||
frameBuffer = display.getFrameBuffer();
|
frameBuffer = display.getFrameBuffer();
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
@@ -38,7 +64,12 @@ void GfxRenderer::begin() {
|
|||||||
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
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
|
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
|
||||||
// This should always be inlined for better performance
|
// 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 {
|
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);
|
const auto fontIt = fontMap.find(fontId);
|
||||||
if (fontIt == fontMap.end()) {
|
if (fontIt == fontMap.end()) {
|
||||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
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,
|
int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
|
||||||
const EpdFontFamily::Style style) const {
|
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);
|
const auto fontIt = fontMap.find(fontId);
|
||||||
if (fontIt == fontMap.end()) return 0;
|
if (fontIt == fontMap.end()) return 0;
|
||||||
const auto& font = fontIt->second;
|
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 {
|
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);
|
const auto fontIt = fontMap.find(fontId);
|
||||||
if (fontIt == fontMap.end()) {
|
if (fontIt == fontMap.end()) {
|
||||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <HalDisplay.h>
|
#include <HalDisplay.h>
|
||||||
|
|
||||||
class FontCacheManager;
|
class FontCacheManager;
|
||||||
|
class SdCardFont;
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <map>
|
#include <map>
|
||||||
@@ -42,6 +43,11 @@ class GfxRenderer {
|
|||||||
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
|
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
|
||||||
std::vector<uint8_t*> bwBufferChunks;
|
std::vector<uint8_t*> bwBufferChunks;
|
||||||
std::map<int, EpdFontFamily> fontMap;
|
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
|
// Mutable because drawText() is const but needs to delegate scan-mode
|
||||||
// recording to the (non-const) FontCacheManager. Same pragmatic compromise
|
// recording to the (non-const) FontCacheManager. Same pragmatic compromise
|
||||||
@@ -69,9 +75,25 @@ class GfxRenderer {
|
|||||||
// Setup
|
// Setup
|
||||||
void begin(); // must be called right after display.begin()
|
void begin(); // must be called right after display.begin()
|
||||||
void insertFont(int fontId, EpdFontFamily font);
|
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; }
|
void setFontCacheManager(FontCacheManager* m) { fontCacheManager_ = m; }
|
||||||
FontCacheManager* getFontCacheManager() const { return fontCacheManager_; }
|
FontCacheManager* getFontCacheManager() const { return fontCacheManager_; }
|
||||||
const std::map<int, EpdFontFamily>& getFontMap() const { return fontMap; }
|
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)
|
// Orientation control (affects logical width/height and coordinate transforms)
|
||||||
void setOrientation(const Orientation o) { orientation = o; }
|
void setOrientation(const Orientation o) { orientation = o; }
|
||||||
|
|||||||
@@ -309,6 +309,21 @@ STR_DELETE_CONFIRM: "Delete this server?"
|
|||||||
STR_OPDS_SERVERS: "OPDS Servers"
|
STR_OPDS_SERVERS: "OPDS Servers"
|
||||||
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
|
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
|
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_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_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report."
|
||||||
STR_CRASH_REASON: "Crash reason:"
|
STR_CRASH_REASON: "Crash reason:"
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ void utf8TruncateChars(std::string& str, size_t numChars);
|
|||||||
// incomplete trailing bytes are excluded.
|
// incomplete trailing bytes are excluded.
|
||||||
int utf8SafeTruncateBuffer(const char* buf, int len);
|
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.
|
// Returns true for Unicode combining diacritical marks that should not advance the cursor.
|
||||||
inline bool utf8IsCombiningMark(const uint32_t cp) {
|
inline bool utf8IsCombiningMark(const uint32_t cp) {
|
||||||
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
|
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
|
||||||
|
|||||||
Executable
+255
@@ -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()
|
||||||
@@ -253,6 +253,19 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
float CrossPointSettings::getReaderLineCompression() const {
|
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) {
|
switch (fontFamily) {
|
||||||
case NOTOSERIF:
|
case NOTOSERIF:
|
||||||
default:
|
default:
|
||||||
@@ -321,6 +334,13 @@ int CrossPointSettings::getRefreshFrequency() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int CrossPointSettings::getReaderFontId() 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) {
|
switch (fontFamily) {
|
||||||
case NOTOSERIF:
|
case NOTOSERIF:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ class CrossPointSettings {
|
|||||||
// Swapped: Next, Previous
|
// Swapped: Next, Previous
|
||||||
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTON_LAYOUT_COUNT };
|
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 };
|
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
|
// Font size options
|
||||||
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT };
|
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 };
|
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT };
|
||||||
@@ -212,6 +213,8 @@ class CrossPointSettings {
|
|||||||
uint8_t fadingFix = 0;
|
uint8_t fadingFix = 0;
|
||||||
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
|
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
|
||||||
uint8_t embeddedStyle = 1;
|
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)
|
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
|
||||||
uint8_t showHiddenFiles = 0;
|
uint8_t showHiddenFiles = 0;
|
||||||
// Image rendering mode in EPUB reader
|
// Image rendering mode in EPUB reader
|
||||||
@@ -226,6 +229,12 @@ class CrossPointSettings {
|
|||||||
// Get singleton instance
|
// Get singleton instance
|
||||||
static CrossPointSettings& getInstance() { return 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 {
|
uint16_t getPowerButtonDuration() const {
|
||||||
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
|
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -140,6 +140,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
|||||||
doc["frontButtonConfirm"] = s.frontButtonConfirm;
|
doc["frontButtonConfirm"] = s.frontButtonConfirm;
|
||||||
doc["frontButtonLeft"] = s.frontButtonLeft;
|
doc["frontButtonLeft"] = s.frontButtonLeft;
|
||||||
doc["frontButtonRight"] = s.frontButtonRight;
|
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.
|
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
// 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);
|
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
|
||||||
CrossPointSettings::validateFrontButtonMapping(s);
|
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.
|
// Language -- stored as code string for stability across enum reorders.
|
||||||
if (doc["language"].is<const char*>()) {
|
if (doc["language"].is<const char*>()) {
|
||||||
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
|
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SdCardFontManager.h>
|
||||||
|
#include <SdCardFontRegistry.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
|
||||||
|
/// Facade that owns the SD card font registry, manager, and resolver logic.
|
||||||
|
/// Hides implementation details behind a single begin() + ensureLoaded() API.
|
||||||
|
class SdCardFontSystem {
|
||||||
|
public:
|
||||||
|
SdCardFontSystem() = default;
|
||||||
|
SdCardFontSystem(const SdCardFontSystem&) = delete;
|
||||||
|
SdCardFontSystem& operator=(const SdCardFontSystem&) = delete;
|
||||||
|
/// Discover SD card fonts and load user's saved selection. Call once during setup.
|
||||||
|
void begin(GfxRenderer& renderer);
|
||||||
|
|
||||||
|
/// Ensure the correct SD font family is loaded for the current settings.
|
||||||
|
/// Call before entering the reader or after settings change.
|
||||||
|
/// Also re-discovers if the registry has been marked dirty (e.g. by web upload).
|
||||||
|
void ensureLoaded(GfxRenderer& renderer);
|
||||||
|
|
||||||
|
/// Resolve an SD card font ID from family name + fontSize enum.
|
||||||
|
/// Returns 0 if not found. Used by CrossPointSettings::getReaderFontId().
|
||||||
|
int resolveFontId(const char* familyName, uint8_t fontSizeEnum) const;
|
||||||
|
|
||||||
|
/// Access the registry (e.g. for settings UI to enumerate available fonts).
|
||||||
|
const SdCardFontRegistry& registry() const { return registry_; }
|
||||||
|
|
||||||
|
/// Non-const access to the registry (for FontInstaller).
|
||||||
|
SdCardFontRegistry& registry() { return registry_; }
|
||||||
|
|
||||||
|
/// Mark the registry as needing re-discovery.
|
||||||
|
/// Thread-safe: can be called from the web server task.
|
||||||
|
void markRegistryDirty() { registryDirty_.store(true, std::memory_order_release); }
|
||||||
|
|
||||||
|
/// If the registry is dirty, re-scan the SD card now and clear the flag.
|
||||||
|
/// Used by the web UI so uploaded/deleted fonts appear in the list
|
||||||
|
/// without waiting for the reader activity to run ensureLoaded().
|
||||||
|
void refreshIfDirty() {
|
||||||
|
if (registryDirty_.exchange(false, std::memory_order_acquire)) {
|
||||||
|
registry_.discover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
SdCardFontRegistry registry_;
|
||||||
|
SdCardFontManager manager_;
|
||||||
|
std::atomic<bool> registryDirty_{false};
|
||||||
|
};
|
||||||
+101
-3
@@ -2,18 +2,105 @@
|
|||||||
|
|
||||||
#include <HalTiltSensor.h>
|
#include <HalTiltSensor.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
#include <SdCardFontRegistry.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <iterator>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
#include "activities/settings/SettingsActivity.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.
|
// 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).
|
// Each entry has a key (for JSON API) and category (for grouping).
|
||||||
// ACTION-type entries and entries without a key are device-only.
|
// 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 = {
|
std::vector<SettingInfo> v = {
|
||||||
// --- Display ---
|
// --- Display ---
|
||||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||||
@@ -40,6 +127,8 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
|||||||
StrId::STR_CAT_DISPLAY),
|
StrId::STR_CAT_DISPLAY),
|
||||||
|
|
||||||
// --- Reader ---
|
// --- 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,
|
SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
|
||||||
{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
|
{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
|
||||||
StrId::STR_CAT_READER),
|
StrId::STR_CAT_READER),
|
||||||
@@ -78,6 +167,7 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
|||||||
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
||||||
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH},
|
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH},
|
||||||
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
||||||
|
|
||||||
// --- System ---
|
// --- System ---
|
||||||
SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout,
|
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},
|
{StrId::STR_MIN_1, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15, StrId::STR_MIN_30},
|
||||||
@@ -149,5 +239,13 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
|||||||
}
|
}
|
||||||
return v;
|
return 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
#include "SdCardFontGlobals.h"
|
||||||
#include "boot_sleep/BootActivity.h"
|
#include "boot_sleep/BootActivity.h"
|
||||||
#include "boot_sleep/SleepActivity.h"
|
#include "boot_sleep/SleepActivity.h"
|
||||||
#include "browser/OpdsBookBrowserActivity.h"
|
#include "browser/OpdsBookBrowserActivity.h"
|
||||||
@@ -193,6 +194,7 @@ void ActivityManager::goToBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ActivityManager::goToReader(std::string path) {
|
void ActivityManager::goToReader(std::string path) {
|
||||||
|
ensureSdFontLoaded();
|
||||||
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
|
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -582,6 +582,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
SETTINGS.imageRendering)) {
|
SETTINGS.imageRendering)) {
|
||||||
LOG_DBG("ERS", "Cache not found, building...");
|
LOG_DBG("ERS", "Cache not found, building...");
|
||||||
|
|
||||||
|
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||||
|
|
||||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||||
|
|
||||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
@@ -745,27 +747,19 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
const int orientedMarginRight, const int orientedMarginBottom,
|
const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
const int orientedMarginLeft) {
|
const int orientedMarginLeft) {
|
||||||
const auto t0 = millis();
|
const auto t0 = millis();
|
||||||
auto* fcm = renderer.getFontCacheManager();
|
|
||||||
fcm->resetStats();
|
|
||||||
|
|
||||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
// 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();
|
auto scope = fcm->createPrewarmScope();
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
|
||||||
scope.endScanAndPrewarm();
|
scope.endScanAndPrewarm();
|
||||||
const uint32_t heapAfter = esp_get_free_heap_size();
|
|
||||||
fcm->logStats("prewarm");
|
|
||||||
const auto tPrewarm = millis();
|
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
|
// Force special handling for pages with images when anti-aliasing is on
|
||||||
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
||||||
|
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
renderStatusBar();
|
renderStatusBar();
|
||||||
fcm->logStats("bw_render");
|
|
||||||
const auto tBwRender = millis();
|
const auto tBwRender = millis();
|
||||||
|
|
||||||
if (imagePageWithAA) {
|
if (imagePageWithAA) {
|
||||||
@@ -816,8 +810,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
renderer.displayGrayBuffer();
|
renderer.displayGrayBuffer();
|
||||||
const auto tGrayDisplay = millis();
|
const auto tGrayDisplay = millis();
|
||||||
renderer.setRenderMode(GfxRenderer::BW);
|
renderer.setRenderMode(GfxRenderer::BW);
|
||||||
fcm->logStats("gray");
|
|
||||||
|
|
||||||
// restore the bw data
|
// restore the bw data
|
||||||
renderer.restoreBwBuffer();
|
renderer.restoreBwBuffer();
|
||||||
const auto tBwRestore = millis();
|
const auto tBwRestore = millis();
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
#include "FontDownloadActivity.h"
|
||||||
|
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "SdCardFontGlobals.h"
|
||||||
|
#include "activities/network/WifiSelectionActivity.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
#include "network/HttpDownloader.h"
|
||||||
|
|
||||||
|
FontDownloadActivity::FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||||
|
: Activity("FontDownload", renderer, mappedInput), fontInstaller_(sdFontSystem.registry()) {}
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
|
||||||
|
void FontDownloadActivity::onEnter() {
|
||||||
|
Activity::onEnter();
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
|
||||||
|
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontDownloadActivity::onExit() {
|
||||||
|
Activity::onExit();
|
||||||
|
WiFi.disconnect(false);
|
||||||
|
delay(100);
|
||||||
|
WiFi.mode(WIFI_OFF);
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontDownloadActivity::onWifiSelectionComplete(const bool success) {
|
||||||
|
if (!success) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = LOADING_MANIFEST;
|
||||||
|
}
|
||||||
|
requestUpdateAndWait();
|
||||||
|
|
||||||
|
if (!fetchAndParseManifest()) {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = ERROR;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = FAMILY_LIST;
|
||||||
|
selectedIndex_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Manifest fetching ---
|
||||||
|
|
||||||
|
bool FontDownloadActivity::fetchAndParseManifest() {
|
||||||
|
// Download manifest to a temp file on SD card to avoid holding both
|
||||||
|
// TLS buffers and the full JSON string in RAM simultaneously.
|
||||||
|
static constexpr const char* MANIFEST_TMP = "/fonts_manifest.tmp";
|
||||||
|
|
||||||
|
auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr);
|
||||||
|
if (result != HttpDownloader::OK) {
|
||||||
|
LOG_ERR("FONT", "Failed to fetch manifest from %s", FONT_MANIFEST_URL);
|
||||||
|
errorMessage_ = "Failed to fetch font list";
|
||||||
|
Storage.remove(MANIFEST_TMP);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP client is now closed — TLS buffers freed. Parse JSON from file.
|
||||||
|
FsFile manifestFile;
|
||||||
|
if (!Storage.openFileForRead("FONT", MANIFEST_TMP, manifestFile)) {
|
||||||
|
LOG_ERR("FONT", "Failed to open temp manifest");
|
||||||
|
Storage.remove(MANIFEST_TMP);
|
||||||
|
errorMessage_ = "Failed to read font list";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, manifestFile);
|
||||||
|
manifestFile.close();
|
||||||
|
Storage.remove(MANIFEST_TMP);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
LOG_ERR("FONT", "Manifest parse error: %s", err.c_str());
|
||||||
|
errorMessage_ = "Invalid font manifest";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int version = doc["version"] | 0;
|
||||||
|
if (version != FONTS_MANIFEST_VERSION) {
|
||||||
|
LOG_ERR("FONT", "Unsupported manifest version: %d", version);
|
||||||
|
errorMessage_ = "Unsupported manifest version";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl_ = doc["baseUrl"] | "";
|
||||||
|
families_.clear();
|
||||||
|
|
||||||
|
JsonArray familiesArr = doc["families"].as<JsonArray>();
|
||||||
|
families_.reserve(familiesArr.size());
|
||||||
|
|
||||||
|
for (JsonObject fObj : familiesArr) {
|
||||||
|
ManifestFamily family;
|
||||||
|
family.name = fObj["name"] | "";
|
||||||
|
family.description = fObj["description"] | "";
|
||||||
|
|
||||||
|
for (JsonVariant s : fObj["styles"].as<JsonArray>()) {
|
||||||
|
family.styles.push_back(s.as<std::string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
family.totalSize = 0;
|
||||||
|
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
|
||||||
|
ManifestFile file;
|
||||||
|
file.name = fileObj["name"] | "";
|
||||||
|
file.size = fileObj["size"] | 0;
|
||||||
|
family.totalSize += file.size;
|
||||||
|
family.files.push_back(std::move(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
family.installed = fontInstaller_.isFamilyInstalled(family.name.c_str());
|
||||||
|
|
||||||
|
// Detect updates by comparing manifest file sizes with files on disk.
|
||||||
|
// Not a checksum, but a size mismatch reliably indicates a rebuild in practice.
|
||||||
|
if (family.installed) {
|
||||||
|
for (const auto& file : family.files) {
|
||||||
|
char path[128];
|
||||||
|
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path));
|
||||||
|
FsFile f;
|
||||||
|
if (Storage.openFileForRead("FONT", path, f)) {
|
||||||
|
size_t actual = f.fileSize();
|
||||||
|
f.close();
|
||||||
|
if (actual != file.size) {
|
||||||
|
family.hasUpdate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File missing on disk but family dir exists — treat as update
|
||||||
|
family.hasUpdate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
families_.push_back(std::move(family));
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("FONT", "Manifest loaded: %zu families", families_.size());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Download ---
|
||||||
|
|
||||||
|
void FontDownloadActivity::downloadAll() {
|
||||||
|
for (size_t i = 0; i < families_.size(); i++) {
|
||||||
|
if (families_[i].installed && !families_[i].hasUpdate) continue;
|
||||||
|
downloadFamily(families_[i]);
|
||||||
|
if (state_ == ERROR) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = COMPLETE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t FontDownloadActivity::totalUninstalledSize() const {
|
||||||
|
size_t total = 0;
|
||||||
|
for (const auto& f : families_) {
|
||||||
|
if (!f.installed || f.hasUpdate) total += f.totalSize;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = DOWNLOADING;
|
||||||
|
downloadingFamilyIndex_ = static_cast<int>(&family - families_.data());
|
||||||
|
currentFileIndex_ = 0;
|
||||||
|
currentFileTotal_ = family.files.size();
|
||||||
|
fileProgress_ = 0;
|
||||||
|
fileTotal_ = 0;
|
||||||
|
}
|
||||||
|
requestUpdateAndWait();
|
||||||
|
|
||||||
|
if (!fontInstaller_.ensureFamilyDir(family.name.c_str())) {
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = ERROR;
|
||||||
|
errorMessage_ = "Failed to create font directory";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < family.files.size(); i++) {
|
||||||
|
const auto& file = family.files[i];
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
currentFileIndex_ = i;
|
||||||
|
fileProgress_ = 0;
|
||||||
|
fileTotal_ = file.size;
|
||||||
|
}
|
||||||
|
requestUpdateAndWait();
|
||||||
|
|
||||||
|
char destPath[128];
|
||||||
|
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), destPath, sizeof(destPath));
|
||||||
|
|
||||||
|
std::string url = baseUrl_ + file.name;
|
||||||
|
|
||||||
|
auto result = HttpDownloader::downloadToFile(url, destPath, [this](size_t downloaded, size_t total) {
|
||||||
|
fileProgress_ = downloaded;
|
||||||
|
fileTotal_ = total;
|
||||||
|
requestUpdate(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result != HttpDownloader::OK) {
|
||||||
|
LOG_ERR("FONT", "Download failed: %s (%d)", file.name.c_str(), result);
|
||||||
|
fontInstaller_.deleteFamily(family.name.c_str());
|
||||||
|
family.installed = false;
|
||||||
|
family.hasUpdate = false;
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = ERROR;
|
||||||
|
errorMessage_ = "Download failed: " + file.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fontInstaller_.validateCpfontFile(destPath)) {
|
||||||
|
LOG_ERR("FONT", "Invalid .cpfont: %s", destPath);
|
||||||
|
fontInstaller_.deleteFamily(family.name.c_str());
|
||||||
|
family.installed = false;
|
||||||
|
family.hasUpdate = false;
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = ERROR;
|
||||||
|
errorMessage_ = "Invalid font file: " + file.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fontInstaller_.refreshRegistry();
|
||||||
|
family.installed = true;
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = COMPLETE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input handling ---
|
||||||
|
|
||||||
|
void FontDownloadActivity::loop() {
|
||||||
|
if (state_ == FAMILY_LIST) {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonNavigator_.onNextRelease([this] {
|
||||||
|
if (selectedIndex_ < listItemCount() - 1) {
|
||||||
|
selectedIndex_++;
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator_.onPreviousRelease([this] {
|
||||||
|
if (selectedIndex_ > 0) {
|
||||||
|
selectedIndex_--;
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (!families_.empty()) {
|
||||||
|
if (isDownloadAllSelected()) {
|
||||||
|
downloadAll();
|
||||||
|
} else {
|
||||||
|
const auto& family = families_[familyIndexFromList(selectedIndex_)];
|
||||||
|
if (!family.installed || family.hasUpdate) {
|
||||||
|
downloadFamily(families_[familyIndexFromList(selectedIndex_)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestUpdateAndWait();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (state_ == COMPLETE) {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = FAMILY_LIST;
|
||||||
|
}
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
} else if (state_ == ERROR) {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = FAMILY_LIST;
|
||||||
|
}
|
||||||
|
requestUpdate();
|
||||||
|
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast<int>(families_.size())) {
|
||||||
|
downloadFamily(families_[downloadingFamilyIndex_]);
|
||||||
|
requestUpdateAndWait();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state_ = FAMILY_LIST;
|
||||||
|
}
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rendering ---
|
||||||
|
|
||||||
|
std::string FontDownloadActivity::formatSize(size_t bytes) {
|
||||||
|
char buf[32];
|
||||||
|
if (bytes >= 1024 * 1024) {
|
||||||
|
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
|
||||||
|
} else if (bytes >= 1024) {
|
||||||
|
snprintf(buf, sizeof(buf), "%.0f KB", static_cast<double>(bytes) / 1024.0);
|
||||||
|
} else {
|
||||||
|
snprintf(buf, sizeof(buf), "%zu B", bytes);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontDownloadActivity::render(RenderLock&&) {
|
||||||
|
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_DOWNLOAD));
|
||||||
|
|
||||||
|
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||||
|
const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||||
|
const auto centerY = (pageHeight - lineHeight) / 2;
|
||||||
|
|
||||||
|
if (state_ == LOADING_MANIFEST) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_LOADING_FONT_LIST));
|
||||||
|
} else if (state_ == FAMILY_LIST) {
|
||||||
|
if (families_.empty()) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_NO_FONTS_AVAILABLE));
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
} else {
|
||||||
|
GUI.drawList(
|
||||||
|
renderer,
|
||||||
|
Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing},
|
||||||
|
listItemCount(), selectedIndex_,
|
||||||
|
[this](int index) -> std::string {
|
||||||
|
if (index == 0) {
|
||||||
|
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalUninstalledSize()) + ")";
|
||||||
|
}
|
||||||
|
return families_[familyIndexFromList(index)].name;
|
||||||
|
},
|
||||||
|
nullptr, nullptr,
|
||||||
|
[this](int index) -> std::string {
|
||||||
|
if (index == 0) return "";
|
||||||
|
const auto& f = families_[familyIndexFromList(index)];
|
||||||
|
if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
|
||||||
|
if (f.installed) return tr(STR_INSTALLED);
|
||||||
|
return f.description;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
[this](int index) -> bool {
|
||||||
|
if (index == 0) return false;
|
||||||
|
const auto& f = families_[familyIndexFromList(index)];
|
||||||
|
// Dim installed fonts, but not those with updates available
|
||||||
|
return f.installed && !f.hasUpdate;
|
||||||
|
});
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
} else if (state_ == DOWNLOADING) {
|
||||||
|
const auto& family = families_[downloadingFamilyIndex_];
|
||||||
|
|
||||||
|
std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + family.name + " (" +
|
||||||
|
std::to_string(currentFileIndex_ + 1) + "/" + std::to_string(currentFileTotal_) + ")";
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, statusText.c_str());
|
||||||
|
|
||||||
|
float progress = 0;
|
||||||
|
if (fileTotal_ > 0) {
|
||||||
|
progress = static_cast<float>(fileProgress_) / static_cast<float>(fileTotal_);
|
||||||
|
}
|
||||||
|
|
||||||
|
int barY = centerY + metrics.verticalSpacing;
|
||||||
|
GUI.drawProgressBar(
|
||||||
|
renderer,
|
||||||
|
Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
|
||||||
|
static_cast<int>(progress * 100), 100);
|
||||||
|
|
||||||
|
int percentY = barY + metrics.progressBarHeight + metrics.verticalSpacing;
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, percentY,
|
||||||
|
(std::to_string(static_cast<int>(progress * 100)) + "%").c_str());
|
||||||
|
} else if (state_ == COMPLETE) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD);
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
} else if (state_ == ERROR) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_FONT_INSTALL_FAILED), true,
|
||||||
|
EpdFontFamily::BOLD);
|
||||||
|
if (!errorMessage_.empty()) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str());
|
||||||
|
}
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "FontInstaller.h"
|
||||||
|
#include "SdCardFont.h"
|
||||||
|
#include "activities/Activity.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
// JSON schema version of the fonts.json manifest. The canonical version for
|
||||||
|
// the build tooling lives in lib/EpdFont/scripts/cpfont_version.py. This
|
||||||
|
// firmware-side copy must be bumped manually when the firmware is updated to
|
||||||
|
// support a new manifest schema.
|
||||||
|
#define FONTS_MANIFEST_VERSION 1
|
||||||
|
|
||||||
|
#ifndef FONT_MANIFEST_URL
|
||||||
|
// Manifest + .cpfont assets are published by .github/workflows/release-fonts.yml
|
||||||
|
// to the crosspoint-fonts repo under the "sd-fonts-m<META>-b<BIN>" tag. The tag
|
||||||
|
// pattern must stay in sync with the workflow; it derives its version numbers
|
||||||
|
// from lib/EpdFont/scripts/cpfont_version.py.
|
||||||
|
#define FONT_MANIFEST_URL_STRINGIFY_INNER(x) #x
|
||||||
|
#define FONT_MANIFEST_URL_STRINGIFY(x) FONT_MANIFEST_URL_STRINGIFY_INNER(x)
|
||||||
|
#define FONT_MANIFEST_URL \
|
||||||
|
"https://github.com/crosspoint-reader/crosspoint-fonts/releases/download/sd-fonts-m" FONT_MANIFEST_URL_STRINGIFY( \
|
||||||
|
FONTS_MANIFEST_VERSION) "-b" FONT_MANIFEST_URL_STRINGIFY(CPFONT_VERSION) "/fonts.json"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
class FontDownloadActivity : public Activity {
|
||||||
|
public:
|
||||||
|
explicit FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput);
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
void render(RenderLock&&) override;
|
||||||
|
bool preventAutoSleep() override { return state_ == LOADING_MANIFEST || state_ == DOWNLOADING; }
|
||||||
|
bool skipLoopDelay() override { return true; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum State {
|
||||||
|
WIFI_SELECTION,
|
||||||
|
LOADING_MANIFEST,
|
||||||
|
FAMILY_LIST,
|
||||||
|
DOWNLOADING,
|
||||||
|
COMPLETE,
|
||||||
|
ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ManifestFile {
|
||||||
|
std::string name;
|
||||||
|
size_t size = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ManifestFamily {
|
||||||
|
std::string name;
|
||||||
|
std::string description;
|
||||||
|
std::vector<std::string> styles;
|
||||||
|
std::vector<ManifestFile> files;
|
||||||
|
size_t totalSize = 0;
|
||||||
|
bool installed = false;
|
||||||
|
bool hasUpdate = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
State state_ = WIFI_SELECTION;
|
||||||
|
FontInstaller fontInstaller_;
|
||||||
|
ButtonNavigator buttonNavigator_;
|
||||||
|
|
||||||
|
// Manifest data
|
||||||
|
std::string baseUrl_;
|
||||||
|
std::vector<ManifestFamily> families_;
|
||||||
|
int selectedIndex_ = 0;
|
||||||
|
|
||||||
|
// Download progress
|
||||||
|
size_t currentFileIndex_ = 0;
|
||||||
|
size_t currentFileTotal_ = 0;
|
||||||
|
size_t fileProgress_ = 0;
|
||||||
|
size_t fileTotal_ = 0;
|
||||||
|
int downloadingFamilyIndex_ = 0;
|
||||||
|
std::string errorMessage_;
|
||||||
|
|
||||||
|
void onWifiSelectionComplete(bool success);
|
||||||
|
bool fetchAndParseManifest();
|
||||||
|
void downloadFamily(ManifestFamily& family);
|
||||||
|
void downloadAll();
|
||||||
|
bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); }
|
||||||
|
int familyIndexFromList(int listIndex) const { return listIndex - 1; }
|
||||||
|
int listItemCount() const { return families_.empty() ? 0 : static_cast<int>(families_.size()) + 1; }
|
||||||
|
size_t totalUninstalledSize() const;
|
||||||
|
static std::string formatSize(size_t bytes);
|
||||||
|
};
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#include "FontSelectionActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const SdCardFontRegistry* registry)
|
||||||
|
: Activity("FontSelect", renderer, mappedInput), registry_(registry) {}
|
||||||
|
|
||||||
|
void FontSelectionActivity::onEnter() {
|
||||||
|
Activity::onEnter();
|
||||||
|
|
||||||
|
// Build combined font list: built-in + SD card fonts
|
||||||
|
fonts_.clear();
|
||||||
|
fonts_.reserve(CrossPointSettings::BUILTIN_FONT_COUNT + (registry_ ? registry_->getFamilyCount() : 0));
|
||||||
|
|
||||||
|
fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, 0});
|
||||||
|
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, 1});
|
||||||
|
fonts_.push_back({I18N.get(StrId::STR_OPEN_DYSLEXIC), true, 2});
|
||||||
|
|
||||||
|
if (registry_) {
|
||||||
|
const auto& families = registry_->getFamilies();
|
||||||
|
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||||
|
fonts_.push_back({families[i].name, false, static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current selection
|
||||||
|
selectedIndex_ = 0;
|
||||||
|
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
|
||||||
|
const auto& families = registry_->getFamilies();
|
||||||
|
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||||
|
if (families[i].name == SETTINGS.sdFontFamilyName) {
|
||||||
|
selectedIndex_ = CrossPointSettings::BUILTIN_FONT_COUNT + i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontSelectionActivity::onExit() { Activity::onExit(); }
|
||||||
|
|
||||||
|
void FontSelectionActivity::loop() {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
handleSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonNavigator_.onNextRelease([this] {
|
||||||
|
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, static_cast<int>(fonts_.size()));
|
||||||
|
requestUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator_.onPreviousRelease([this] {
|
||||||
|
selectedIndex_ = ButtonNavigator::previousIndex(selectedIndex_, static_cast<int>(fonts_.size()));
|
||||||
|
requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontSelectionActivity::handleSelection() {
|
||||||
|
const auto& font = fonts_[selectedIndex_];
|
||||||
|
if (font.settingIndex < CrossPointSettings::BUILTIN_FONT_COUNT) {
|
||||||
|
SETTINGS.fontFamily = font.settingIndex;
|
||||||
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
|
} else if (registry_) {
|
||||||
|
int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
|
||||||
|
const auto& families = registry_->getFamilies();
|
||||||
|
if (sdIdx < static_cast<int>(families.size())) {
|
||||||
|
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||||
|
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontSelectionActivity::render(RenderLock&&) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||||
|
|
||||||
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_FAMILY));
|
||||||
|
|
||||||
|
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||||
|
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||||
|
|
||||||
|
// Determine which font index is currently active (to mark as "Selected")
|
||||||
|
int currentFontIndex = 0;
|
||||||
|
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
|
||||||
|
const auto& families = registry_->getFamilies();
|
||||||
|
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||||
|
if (families[i].name == SETTINGS.sdFontFamilyName) {
|
||||||
|
currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentFontIndex = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
GUI.drawList(
|
||||||
|
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
|
||||||
|
[this](int index) { return fonts_[index].name; }, nullptr, nullptr,
|
||||||
|
[this, currentFontIndex](int index) -> std::string { return index == currentFontIndex ? tr(STR_SELECTED) : ""; },
|
||||||
|
true);
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SdCardFontRegistry.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "activities/Activity.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class FontSelectionActivity final : public Activity {
|
||||||
|
public:
|
||||||
|
explicit FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const SdCardFontRegistry* registry);
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
void render(RenderLock&&) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void handleSelection();
|
||||||
|
|
||||||
|
struct FontEntry {
|
||||||
|
std::string name;
|
||||||
|
bool isBuiltin;
|
||||||
|
uint8_t settingIndex; // index used by valueSetter
|
||||||
|
};
|
||||||
|
|
||||||
|
const SdCardFontRegistry* registry_;
|
||||||
|
ButtonNavigator buttonNavigator_;
|
||||||
|
std::vector<FontEntry> fonts_;
|
||||||
|
int selectedIndex_ = 0;
|
||||||
|
};
|
||||||
@@ -6,11 +6,14 @@
|
|||||||
#include "ButtonRemapActivity.h"
|
#include "ButtonRemapActivity.h"
|
||||||
#include "ClearCacheActivity.h"
|
#include "ClearCacheActivity.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
#include "FontDownloadActivity.h"
|
||||||
|
#include "FontSelectionActivity.h"
|
||||||
#include "KOReaderSettingsActivity.h"
|
#include "KOReaderSettingsActivity.h"
|
||||||
#include "LanguageSelectActivity.h"
|
#include "LanguageSelectActivity.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerListActivity.h"
|
#include "OpdsServerListActivity.h"
|
||||||
#include "OtaUpdateActivity.h"
|
#include "OtaUpdateActivity.h"
|
||||||
|
#include "SdCardFontGlobals.h"
|
||||||
#include "SdFirmwareUpdateActivity.h"
|
#include "SdFirmwareUpdateActivity.h"
|
||||||
#include "SettingsList.h"
|
#include "SettingsList.h"
|
||||||
#include "StatusBarSettingsActivity.h"
|
#include "StatusBarSettingsActivity.h"
|
||||||
@@ -21,16 +24,17 @@
|
|||||||
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
|
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
|
||||||
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
|
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
|
||||||
|
|
||||||
void SettingsActivity::onEnter() {
|
void SettingsActivity::rebuildSettingsLists() {
|
||||||
Activity::onEnter();
|
|
||||||
|
|
||||||
// Build per-category vectors from the shared settings list
|
|
||||||
displaySettings.clear();
|
displaySettings.clear();
|
||||||
readerSettings.clear();
|
readerSettings.clear();
|
||||||
controlsSettings.clear();
|
controlsSettings.clear();
|
||||||
systemSettings.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_NONE_OPT) continue;
|
||||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||||
displaySettings.push_back(setting);
|
displaySettings.push_back(setting);
|
||||||
@@ -41,7 +45,6 @@ void SettingsActivity::onEnter() {
|
|||||||
} else if (setting.category == StrId::STR_CAT_SYSTEM) {
|
} else if (setting.category == StrId::STR_CAT_SYSTEM) {
|
||||||
systemSettings.push_back(setting);
|
systemSettings.push_back(setting);
|
||||||
}
|
}
|
||||||
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append device-only ACTION items
|
// 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_KOREADER_SYNC, SettingAction::KOReaderSync));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
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_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_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
|
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));
|
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
|
// Reset selection to first category
|
||||||
selectedCategoryIndex = 0;
|
selectedCategoryIndex = 0;
|
||||||
selectedSettingIndex = 0;
|
selectedSettingIndex = 0;
|
||||||
|
|
||||||
// Initialize with first category (Display)
|
rebuildSettingsLists();
|
||||||
currentSettings = &displaySettings;
|
|
||||||
settingsCount = static_cast<int>(displaySettings.size());
|
|
||||||
|
|
||||||
// Trigger first update
|
// Trigger first update
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
@@ -159,6 +185,21 @@ void SettingsActivity::toggleCurrentSetting() {
|
|||||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||||
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||||
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
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) {
|
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||||
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||||
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
|
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
|
||||||
@@ -194,6 +235,13 @@ void SettingsActivity::toggleCurrentSetting() {
|
|||||||
case SettingAction::SdFirmwareUpdate:
|
case SettingAction::SdFirmwareUpdate:
|
||||||
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
|
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
|
||||||
break;
|
break;
|
||||||
|
case SettingAction::DownloadFonts:
|
||||||
|
startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput),
|
||||||
|
[this](const ActivityResult&) {
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
rebuildSettingsLists();
|
||||||
|
});
|
||||||
|
break;
|
||||||
case SettingAction::Language:
|
case SettingAction::Language:
|
||||||
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
||||||
break;
|
break;
|
||||||
@@ -245,6 +293,13 @@ void SettingsActivity::render(RenderLock&&) {
|
|||||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||||
const uint8_t value = SETTINGS.*(setting.valuePtr);
|
const uint8_t value = SETTINGS.*(setting.valuePtr);
|
||||||
valueText = I18N.get(setting.enumValues[value]);
|
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) {
|
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||||
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
|
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ enum class SettingAction {
|
|||||||
CheckForUpdates,
|
CheckForUpdates,
|
||||||
SdFirmwareUpdate,
|
SdFirmwareUpdate,
|
||||||
Language,
|
Language,
|
||||||
|
DownloadFonts,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SettingInfo {
|
struct SettingInfo {
|
||||||
@@ -29,6 +30,7 @@ struct SettingInfo {
|
|||||||
SettingType type;
|
SettingType type;
|
||||||
uint8_t CrossPointSettings::* valuePtr = nullptr;
|
uint8_t CrossPointSettings::* valuePtr = nullptr;
|
||||||
std::vector<StrId> enumValues;
|
std::vector<StrId> enumValues;
|
||||||
|
std::vector<std::string> enumStringValues; // runtime alternative to StrId enumValues (for SD card fonts etc.)
|
||||||
SettingAction action = SettingAction::None;
|
SettingAction action = SettingAction::None;
|
||||||
|
|
||||||
struct ValueRange {
|
struct ValueRange {
|
||||||
@@ -159,6 +161,7 @@ class SettingsActivity final : public Activity {
|
|||||||
|
|
||||||
void enterCategory(int categoryIndex);
|
void enterCategory(int categoryIndex);
|
||||||
void toggleCurrentSetting();
|
void toggleCurrentSetting();
|
||||||
|
void rebuildSettingsLists();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||||
|
|||||||
@@ -231,7 +231,8 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle,
|
const std::function<std::string(int index)>& rowSubtitle,
|
||||||
const std::function<UIIcon(int index)>& rowIcon,
|
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 =
|
int rowHeight =
|
||||||
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
|
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
|
||||||
int pageItems = rect.height / rowHeight;
|
int pageItems = rect.height / rowHeight;
|
||||||
@@ -279,6 +280,16 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
||||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
|
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) {
|
if (rowSubtitle != nullptr) {
|
||||||
// Draw subtitle
|
// Draw subtitle
|
||||||
std::string subtitleText = rowSubtitle(i);
|
std::string subtitleText = rowSubtitle(i);
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ class BaseTheme {
|
|||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
||||||
const std::function<UIIcon(int index)>& rowIcon = nullptr,
|
const std::function<UIIcon(int index)>& rowIcon = nullptr,
|
||||||
const std::function<std::string(int index)>& rowValue = nullptr,
|
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
|
||||||
bool highlightValue = false) const;
|
const std::function<bool(int index)>& rowDimmed = nullptr) const;
|
||||||
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title,
|
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title,
|
||||||
const char* subtitle = nullptr) const;
|
const char* subtitle = nullptr) const;
|
||||||
virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
|
virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
|
||||||
|
|||||||
@@ -208,7 +208,8 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle,
|
const std::function<std::string(int index)>& rowSubtitle,
|
||||||
const std::function<UIIcon(int index)>& rowIcon,
|
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 =
|
int rowHeight =
|
||||||
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
||||||
int pageItems = rect.height / rowHeight;
|
int pageItems = rect.height / rowHeight;
|
||||||
@@ -267,6 +268,15 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
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);
|
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) {
|
if (rowIcon != nullptr) {
|
||||||
UIIcon icon = rowIcon(i);
|
UIIcon icon = rowIcon(i);
|
||||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class LyraTheme : public BaseTheme {
|
|||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle,
|
const std::function<std::string(int index)>& rowSubtitle,
|
||||||
const std::function<UIIcon(int index)>& rowIcon, const std::function<std::string(int index)>& rowValue,
|
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,
|
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
||||||
const char* btn4) const override;
|
const char* btn4) const override;
|
||||||
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;
|
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;
|
||||||
|
|||||||
@@ -247,9 +247,11 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item
|
|||||||
const std::function<std::string(int index)>& rowTitle,
|
const std::function<std::string(int index)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle,
|
const std::function<std::string(int index)>& rowSubtitle,
|
||||||
const std::function<UIIcon(int index)>& rowIcon,
|
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)rowIcon;
|
||||||
(void)highlightValue;
|
(void)highlightValue;
|
||||||
|
(void)rowDimmed;
|
||||||
const bool hasSubtitle = static_cast<bool>(rowSubtitle);
|
const bool hasSubtitle = static_cast<bool>(rowSubtitle);
|
||||||
const int titleLineHeight = renderer.getLineHeight(kTitleFontId);
|
const int titleLineHeight = renderer.getLineHeight(kTitleFontId);
|
||||||
const int subtitleLineHeight = renderer.getLineHeight(kSubtitleFontId);
|
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)>& rowTitle,
|
||||||
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
||||||
const std::function<UIIcon(int index)>& rowIcon = nullptr,
|
const std::function<UIIcon(int index)>& rowIcon = nullptr,
|
||||||
const std::function<std::string(int index)>& rowValue = nullptr,
|
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
|
||||||
bool highlightValue = false) const override;
|
const std::function<bool(int index)>& rowDimmed = nullptr) const override;
|
||||||
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
||||||
const char* btn4) const override;
|
const char* btn4) const override;
|
||||||
bool homeMenuShowsContinueReading() const { return true; }
|
bool homeMenuShowsContinueReading() const { return true; }
|
||||||
|
|||||||
@@ -16,3 +16,21 @@
|
|||||||
#define UI_10_FONT_ID (22918846)
|
#define UI_10_FONT_ID (22918846)
|
||||||
#define UI_12_FONT_ID (1635686837)
|
#define UI_12_FONT_ID (1635686837)
|
||||||
#define SMALL_FONT_ID (674098198)
|
#define SMALL_FONT_ID (674098198)
|
||||||
|
|
||||||
|
// Font ID 0 is reserved as the "not found" sentinel.
|
||||||
|
// Guard against any hash accidentally producing 0.
|
||||||
|
static_assert(NOTOSERIF_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(NOTOSERIF_14_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(NOTOSERIF_16_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(NOTOSERIF_18_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(NOTOSANS_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(NOTOSANS_14_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(NOTOSANS_16_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(NOTOSANS_18_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(OPENDYSLEXIC_8_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(OPENDYSLEXIC_10_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(OPENDYSLEXIC_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(OPENDYSLEXIC_14_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(UI_10_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(UI_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
static_assert(SMALL_FONT_ID != 0, "Font ID collision with sentinel");
|
||||||
|
|||||||
+9
-1
@@ -22,6 +22,7 @@
|
|||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
|
#include "SdCardFontSystem.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
#include "activities/ActivityManager.h"
|
#include "activities/ActivityManager.h"
|
||||||
#include "activities/settings/SdFirmwareUpdateActivity.h"
|
#include "activities/settings/SdFirmwareUpdateActivity.h"
|
||||||
@@ -34,7 +35,8 @@ MappedInputManager mappedInputManager(gpio);
|
|||||||
GfxRenderer renderer(display);
|
GfxRenderer renderer(display);
|
||||||
ActivityManager activityManager(renderer, mappedInputManager);
|
ActivityManager activityManager(renderer, mappedInputManager);
|
||||||
FontDecompressor fontDecompressor;
|
FontDecompressor fontDecompressor;
|
||||||
FontCacheManager fontCacheManager(renderer.getFontMap());
|
SdCardFontSystem sdFontSystem;
|
||||||
|
FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts());
|
||||||
|
|
||||||
// Fonts
|
// Fonts
|
||||||
EpdFont notoserif14RegularFont(¬oserif_14_regular);
|
EpdFont notoserif14RegularFont(¬oserif_14_regular);
|
||||||
@@ -195,6 +197,8 @@ void enterDeepSleep() {
|
|||||||
powerManager.startDeepSleep(gpio);
|
powerManager.startDeepSleep(gpio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ensureSdFontLoaded() { sdFontSystem.ensureLoaded(renderer); }
|
||||||
|
|
||||||
void setupDisplayAndFonts() {
|
void setupDisplayAndFonts() {
|
||||||
display.begin();
|
display.begin();
|
||||||
renderer.begin();
|
renderer.begin();
|
||||||
@@ -225,6 +229,10 @@ void setupDisplayAndFonts() {
|
|||||||
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
|
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
|
||||||
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
|
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
|
||||||
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
|
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
|
||||||
|
|
||||||
|
// Discover and load SD card fonts
|
||||||
|
sdFontSystem.begin(renderer);
|
||||||
|
|
||||||
LOG_DBG("MAIN", "Fonts setup");
|
LOG_DBG("MAIN", "Fonts setup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,15 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
#include "FontInstaller.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
#include "SdCardFontGlobals.h"
|
||||||
|
#include "SdCardFontSystem.h"
|
||||||
#include "SettingsList.h"
|
#include "SettingsList.h"
|
||||||
#include "WebDAVHandler.h"
|
#include "WebDAVHandler.h"
|
||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
#include "html/FilesPageHtml.generated.h"
|
#include "html/FilesPageHtml.generated.h"
|
||||||
|
#include "html/FontsPageHtml.generated.h"
|
||||||
#include "html/HomePageHtml.generated.h"
|
#include "html/HomePageHtml.generated.h"
|
||||||
#include "html/SettingsPageHtml.generated.h"
|
#include "html/SettingsPageHtml.generated.h"
|
||||||
#include "html/js/jszip_minJs.generated.h"
|
#include "html/js/jszip_minJs.generated.h"
|
||||||
@@ -164,6 +168,12 @@ void CrossPointWebServer::begin() {
|
|||||||
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
|
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
|
||||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
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
|
// OPDS server endpoints
|
||||||
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
||||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||||
@@ -1101,7 +1111,10 @@ void CrossPointWebServer::handleSettingsPage() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CrossPointWebServer::handleGetSettings() 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->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
server->send(200, "application/json", "");
|
server->send(200, "application/json", "");
|
||||||
@@ -1136,8 +1149,14 @@ void CrossPointWebServer::handleGetSettings() const {
|
|||||||
doc["value"] = static_cast<int>(s.valueGetter());
|
doc["value"] = static_cast<int>(s.valueGetter());
|
||||||
}
|
}
|
||||||
JsonArray options = doc["options"].to<JsonArray>();
|
JsonArray options = doc["options"].to<JsonArray>();
|
||||||
for (const auto& opt : s.enumValues) {
|
if (!s.enumStringValues.empty()) {
|
||||||
options.add(I18N.get(opt));
|
for (const auto& opt : s.enumStringValues) {
|
||||||
|
options.add(opt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const auto& opt : s.enumValues) {
|
||||||
|
options.add(I18N.get(opt));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1197,7 +1216,7 @@ void CrossPointWebServer::handlePostSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto& settings = getSettingsList();
|
const auto& settings = getSettingsList(&sdFontSystem.registry());
|
||||||
int applied = 0;
|
int applied = 0;
|
||||||
|
|
||||||
for (const auto& s : settings) {
|
for (const auto& s : settings) {
|
||||||
@@ -1215,7 +1234,9 @@ void CrossPointWebServer::handlePostSettings() {
|
|||||||
}
|
}
|
||||||
case SettingType::ENUM: {
|
case SettingType::ENUM: {
|
||||||
const int val = doc[s.key].as<int>();
|
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) {
|
if (s.valuePtr) {
|
||||||
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
|
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
|
||||||
} else if (s.valueSetter) {
|
} else if (s.valueSetter) {
|
||||||
@@ -1694,3 +1715,199 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
|||||||
break;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,6 +108,28 @@ class CrossPointWebServer {
|
|||||||
void handleGetSettings() const;
|
void handleGetSettings() const;
|
||||||
void handlePostSettings();
|
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
|
// OPDS server handlers
|
||||||
void handleGetOpdsServers() const;
|
void handleGetOpdsServers() const;
|
||||||
void handlePostOpdsServer();
|
void handlePostOpdsServer();
|
||||||
|
|||||||
@@ -1465,6 +1465,7 @@
|
|||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/files" class="active">File Manager</a>
|
<a href="/files" class="active">File Manager</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
|
<a href="/fonts">Fonts</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<a href="/" class="active">Home</a>
|
<a href="/" class="active">Home</a>
|
||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
|
<a href="/fonts">Fonts</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
<a href="/settings" class="active">Settings</a>
|
<a href="/settings" class="active">Settings</a>
|
||||||
|
<a href="/fonts">Fonts</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="message" class="message"></div>
|
<div id="message" class="message"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user