Merge branch 'master' into release/1.3.0
This commit is contained in:
@@ -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/
|
||||
/.venv
|
||||
*.local*
|
||||
*.cpfont
|
||||
lib/EpdFont/scripts/downloaded_fonts/
|
||||
lib/EpdFont/scripts/instanced_fonts/
|
||||
lib/EpdFont/scripts/output/
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "open-x4-sdk"]
|
||||
path = open-x4-sdk
|
||||
url = https://github.com/open-x4-epaper/community-sdk.git
|
||||
url = https://github.com/crosspoint-reader/community-sdk.git
|
||||
|
||||
+14
-5
@@ -104,8 +104,17 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
|
||||
-DUSE_UTF8_LONG_NAMES=1 // SD card long filename support
|
||||
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 // Avoid zlib name conflicts
|
||||
-DXML_GE=0 // Disable XML general entities (security)
|
||||
-DDESTRUCTOR_CLOSES_FILE=1 // FsFile destructor auto-closes (SdFat)
|
||||
```
|
||||
|
||||
**DESTRUCTOR_CLOSES_FILE implications**:
|
||||
- SdFat's `FsBaseFile` destructor calls `close()` automatically when the object goes out of scope
|
||||
- **Do NOT add explicit `file.close()` calls** for local `FsFile` variables — the destructor handles it
|
||||
- Explicit `close()` is still required in these cases:
|
||||
1. **Close before delete**: Must close before `Storage.remove()` on the same path
|
||||
2. **Close before reopen**: Must close before reopening the same `FsFile` variable (e.g., write then reopen for read, or rewrite the same path)
|
||||
3. **Member variables**: `FsFile` members persist beyond any single function scope, so close at the intended release point (e.g., in `onExit()`)
|
||||
|
||||
**SINGLE_BUFFER_MODE implications**:
|
||||
- Only ONE framebuffer exists (not double-buffered)
|
||||
- Grayscale rendering requires temporary buffer allocation (`renderer.storeBwBuffer()`)
|
||||
@@ -145,11 +154,11 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
|
||||
// Read from file
|
||||
file.close(); // Explicit close required
|
||||
// No file.close() needed — DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`.
|
||||
**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`. Do NOT add `file.close()` for local variables (see DESTRUCTOR_CLOSES_FILE above).
|
||||
|
||||
---
|
||||
|
||||
@@ -167,7 +176,7 @@ if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
|
||||
|
||||
### Memory Safety and RAII
|
||||
* Smart Pointers: Prefer std::unique_ptr. Avoid std::shared_ptr (unnecessary atomic overhead for a single-core RISC-V).
|
||||
* RAII: Use destructors for cleanup, but call file.close() or vTaskDelete() explicitly for deterministic resource release.
|
||||
* RAII: Use destructors for cleanup. Call `vTaskDelete()` explicitly for deterministic task release. Do NOT call `file.close()` on local `FsFile` variables — `DESTRUCTOR_CLOSES_FILE=1` handles it at scope exit (see Critical Build Flags).
|
||||
|
||||
### ESP32-C3 Platform Pitfalls
|
||||
|
||||
@@ -376,13 +385,13 @@ void enterNewActivity(Activity* activity) {
|
||||
- Activity navigation = `delete` old activity + `new` create next activity
|
||||
- Any memory allocated in `onEnter()` MUST be freed in `onExit()`
|
||||
- FreeRTOS tasks MUST be deleted in `onExit()` before activity destruction
|
||||
- File handles MUST be closed in `onExit()`
|
||||
- Member `FsFile` handles MUST be closed in `onExit()` (local `FsFile` variables auto-close via destructor)
|
||||
|
||||
**Activity Pattern**:
|
||||
```cpp
|
||||
void onEnter() { Activity::onEnter(); /* alloc: buffer, tasks */ render(); }
|
||||
void loop() { mappedInput.update(); /* handle input */ }
|
||||
void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::onExit(); }
|
||||
void onExit() { /* free: vTaskDelete, free buffer, close member FsFiles */ Activity::onExit(); }
|
||||
```
|
||||
|
||||
**Critical**: Free resources in reverse order. Delete tasks BEFORE activity destruction.
|
||||
|
||||
+20
-2
@@ -21,7 +21,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.6.3 Controls](#363-controls)
|
||||
- [3.6.4 System](#364-system)
|
||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||
- [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup)
|
||||
- [3.6.6 Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds)
|
||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||
- [4. Reading Mode](#4-reading-mode)
|
||||
- [Page Turning](#page-turning)
|
||||
@@ -223,7 +224,24 @@ You can also manage OPDS servers from the web interface while in File Transfer m
|
||||
2. Open `http://<device-ip>/settings`.
|
||||
3. Use the **OPDS Servers** card to add, edit, or delete entries.
|
||||
|
||||
#### 3.6.6 KOReader Sync Quick Setup
|
||||
For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds).
|
||||
|
||||
#### 3.6.6 Web Settings (WiFi + OPDS)
|
||||
|
||||
While in **File Transfer** mode, the web settings page includes management cards for both **WiFi Networks** and **OPDS Servers**.
|
||||
|
||||
1. On device: open **File Transfer** and connect to WiFi.
|
||||
1. In a browser, open `http://<device-ip>/settings` or `http://crosspoint.local`.
|
||||
1. In **WiFi Networks**, add, edit, or delete saved network entries (SSID + optional password).
|
||||
1. In **OPDS Servers**, add, edit, or delete OPDS catalogs.
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- Passwords are never shown back in the web UI after saving.
|
||||
- Leaving Password blank while editing keeps the existing saved password unchanged.
|
||||
- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on device-side WiFi connection flow.
|
||||
|
||||
#### 3.6.7 KOReader Sync Quick Setup
|
||||
|
||||
CrossPoint can sync reading progress with KOReader-compatible sync servers.
|
||||
It also interoperates with KOReader apps/devices when they use the same server and credentials.
|
||||
|
||||
@@ -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.
|
||||
@@ -53,6 +53,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an
|
||||
|
||||
## Ukrainian
|
||||
- [mirus-ua](https://github.com/mirus-ua)
|
||||
- [KymAndriy](https://github.com/KymAndriy)
|
||||
|
||||
## Belarusian
|
||||
- [Dexif](https://github.com/dexif)
|
||||
|
||||
+20
-12
@@ -153,24 +153,32 @@ uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const {
|
||||
|
||||
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
|
||||
const int count = data->intervalCount;
|
||||
if (count == 0) return nullptr;
|
||||
if (count == 0 && !data->glyphMissHandler) return nullptr;
|
||||
|
||||
const EpdUnicodeInterval* intervals = data->intervals;
|
||||
const auto* end = intervals + count;
|
||||
if (count > 0) {
|
||||
const EpdUnicodeInterval* intervals = data->intervals;
|
||||
const auto* end = intervals + count;
|
||||
|
||||
// upper_bound: range lookup. Finds the first interval with first > cp, so the
|
||||
// interval just before it is the last one with first <= cp. That's the only
|
||||
// candidate that could contain cp. Then we verify cp <= candidate.last.
|
||||
const auto it = std::upper_bound(
|
||||
intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
|
||||
// upper_bound: range lookup. Finds the first interval with first > cp, so the
|
||||
// interval just before it is the last one with first <= cp. That's the only
|
||||
// candidate that could contain cp. Then we verify cp <= candidate.last.
|
||||
const auto it = std::upper_bound(
|
||||
intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
|
||||
|
||||
if (it != intervals) {
|
||||
const auto& interval = *(it - 1);
|
||||
if (cp <= interval.last) {
|
||||
return &data->glyph[interval.offset + (cp - interval.first)];
|
||||
if (it != intervals) {
|
||||
const auto& interval = *(it - 1);
|
||||
if (cp <= interval.last) {
|
||||
return &data->glyph[interval.offset + (cp - interval.first)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codepoint not in interval table — try on-demand loading (SD card fonts).
|
||||
if (data->glyphMissHandler) {
|
||||
const EpdGlyph* loaded = data->glyphMissHandler(data->glyphMissCtx, cp);
|
||||
if (loaded) return loaded;
|
||||
}
|
||||
|
||||
if (cp != REPLACEMENT_GLYPH) {
|
||||
return getGlyph(REPLACEMENT_GLYPH);
|
||||
}
|
||||
|
||||
@@ -129,4 +129,16 @@ typedef struct {
|
||||
uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols)
|
||||
const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none)
|
||||
uint32_t ligaturePairCount; ///< Number of entries in ligaturePairs
|
||||
|
||||
/// On-demand glyph loading for fonts that don't keep all glyphs in RAM (e.g. SD card fonts).
|
||||
/// Called by getGlyph() when a codepoint is not found in the interval table.
|
||||
/// Returns a valid EpdGlyph* with correct metadata, or nullptr to fall back to the
|
||||
/// replacement glyph. The returned pointer is valid until the next glyphMissHandler
|
||||
/// call that causes a ring-buffer eviction — callers must consume it (measure or draw)
|
||||
/// before requesting another missed glyph.
|
||||
const EpdGlyph* (*glyphMissHandler)(void* ctx, uint32_t codepoint);
|
||||
|
||||
/// Context pointer for glyphMissHandler (typically SdCardFont*). Also used by
|
||||
/// GfxRenderer::getGlyphBitmap() to retrieve overflow bitmaps via SdCardFont.
|
||||
void* glyphMissCtx;
|
||||
} EpdFontData;
|
||||
|
||||
@@ -284,6 +284,43 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8
|
||||
}
|
||||
}
|
||||
|
||||
// Add ligature output glyphs: if both input codepoints of a ligature pair are
|
||||
// in the needed set, the output glyph will be queried during rendering.
|
||||
if (fontData->ligaturePairs && fontData->ligaturePairCount > 0) {
|
||||
for (uint32_t li = 0; li < fontData->ligaturePairCount && glyphCount < MAX_PAGE_GLYPHS; li++) {
|
||||
uint32_t leftCp = fontData->ligaturePairs[li].pair >> 16;
|
||||
uint32_t rightCp = fontData->ligaturePairs[li].pair & 0xFFFF;
|
||||
|
||||
int32_t leftIdx = findGlyphIndex(fontData, leftCp);
|
||||
int32_t rightIdx = findGlyphIndex(fontData, rightCp);
|
||||
if (leftIdx < 0 || rightIdx < 0) continue;
|
||||
|
||||
// Check if both inputs are in neededGlyphs
|
||||
bool hasLeft = false, hasRight = false;
|
||||
for (uint16_t i = 0; i < glyphCount; i++) {
|
||||
if (neededGlyphs[i] == static_cast<uint32_t>(leftIdx)) hasLeft = true;
|
||||
if (neededGlyphs[i] == static_cast<uint32_t>(rightIdx)) hasRight = true;
|
||||
if (hasLeft && hasRight) break;
|
||||
}
|
||||
if (!hasLeft || !hasRight) continue;
|
||||
|
||||
int32_t outIdx = findGlyphIndex(fontData, fontData->ligaturePairs[li].ligatureCp);
|
||||
if (outIdx < 0) continue;
|
||||
|
||||
// Deduplicate
|
||||
bool found = false;
|
||||
for (uint16_t i = 0; i < glyphCount; i++) {
|
||||
if (neededGlyphs[i] == static_cast<uint32_t>(outIdx)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
neededGlyphs[glyphCount++] = static_cast<uint32_t>(outIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (glyphCount == 0) return 0;
|
||||
|
||||
// Step 2: Compute total buffer size and collect unique groups
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
|
||||
@@ -18,11 +18,11 @@ class BookMetadataCache {
|
||||
|
||||
struct SpineEntry {
|
||||
std::string href;
|
||||
size_t cumulativeSize;
|
||||
uint32_t cumulativeSize;
|
||||
int16_t tocIndex;
|
||||
|
||||
SpineEntry() : cumulativeSize(0), tocIndex(-1) {}
|
||||
SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex)
|
||||
SpineEntry(std::string href, const uint32_t cumulativeSize, const int16_t tocIndex)
|
||||
: href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {}
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ class BookMetadataCache {
|
||||
|
||||
private:
|
||||
std::string cachePath;
|
||||
size_t lutOffset;
|
||||
uint32_t lutOffset;
|
||||
uint16_t spineCount;
|
||||
uint16_t tocCount;
|
||||
bool loaded;
|
||||
|
||||
@@ -100,6 +100,37 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
// Apply fixed transforms before any per-line layout work.
|
||||
applyParagraphIndent();
|
||||
|
||||
// Ensure SD card font glyph metrics are loaded before measuring word widths.
|
||||
// For flash-based fonts isSdCardFont() returns false and this block is skipped
|
||||
// entirely — no heap allocation. For SD card fonts this reads glyph metadata
|
||||
// (advanceX only, no bitmaps) for all unique codepoints in this paragraph so
|
||||
// that calculateWordWidths() can measure text without on-demand SD I/O.
|
||||
if (renderer.isSdCardFont(fontId)) {
|
||||
// Reserve upfront so the joined text allocates exactly once. Without this,
|
||||
// paragraphs with many words trigger a chain of vector-like reallocations
|
||||
// inside std::string during layout — visible in prewarm timings for SD fonts.
|
||||
size_t totalSize = hyphenationEnabled ? 1 : 0;
|
||||
if (!words.empty()) totalSize += words.size() - 1; // inter-word spaces
|
||||
for (const auto& w : words) totalSize += w.size();
|
||||
std::string allText;
|
||||
allText.reserve(totalSize);
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
if (i > 0) allText += ' ';
|
||||
allText += words[i];
|
||||
}
|
||||
if (hyphenationEnabled) allText += '-';
|
||||
|
||||
// Style mask: only ask the SD font to load advances for styles actually
|
||||
// used in this paragraph. Style index is the low two bits (regular/bold/
|
||||
// italic/bold-italic); the underline bit is irrelevant to advance metrics.
|
||||
uint8_t styleMask = 0;
|
||||
for (auto s : wordStyles) {
|
||||
styleMask |= static_cast<uint8_t>(1u << (static_cast<uint8_t>(s) & 0x03));
|
||||
}
|
||||
if (styleMask == 0) styleMask = 0x01; // defensive: regular only
|
||||
renderer.ensureSdCardFontReady(fontId, allText.c_str(), styleMask);
|
||||
}
|
||||
|
||||
const int pageWidth = viewportWidth;
|
||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||
|
||||
|
||||
@@ -32,9 +32,15 @@ struct JpegContext {
|
||||
int dstWidth{0};
|
||||
int dstHeight{0};
|
||||
|
||||
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU)
|
||||
int32_t fineScaleFP{1 << 16}; // src -> dst mapping
|
||||
int32_t invScaleFP{1 << 16}; // dst -> src mapping
|
||||
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU).
|
||||
// X and Y axes use separate scale factors: the aspect ratio of the output (dstWidth/dstHeight)
|
||||
// may differ from the source (srcWidth/srcHeight) due to integer rounding of displayHeight.
|
||||
// Using a single (X-based) scale for both axes causes the wrong srcRow to be skipped
|
||||
// during nearest-neighbor downscaling, potentially losing critical image content.
|
||||
int32_t fineScaleFPX{1 << 16}; // X: src -> dst column mapping
|
||||
int32_t invScaleFPX{1 << 16}; // X: dst -> src column mapping
|
||||
int32_t fineScaleFPY{1 << 16}; // Y: src -> dst row mapping
|
||||
int32_t invScaleFPY{1 << 16}; // Y: dst -> src row mapping
|
||||
|
||||
PixelCache cache;
|
||||
bool caching{false};
|
||||
@@ -125,8 +131,10 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
|
||||
const bool useDithering = ctx->config->useDithering;
|
||||
const bool caching = ctx->caching;
|
||||
const int32_t fineScaleFP = ctx->fineScaleFP;
|
||||
const int32_t invScaleFP = ctx->invScaleFP;
|
||||
const int32_t fineScaleFPX = ctx->fineScaleFPX;
|
||||
const int32_t invScaleFPX = ctx->invScaleFPX;
|
||||
const int32_t fineScaleFPY = ctx->fineScaleFPY;
|
||||
const int32_t invScaleFPY = ctx->invScaleFPY;
|
||||
GfxRenderer& renderer = *ctx->renderer;
|
||||
const int cfgX = ctx->config->x;
|
||||
const int cfgY = ctx->config->y;
|
||||
@@ -137,10 +145,10 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
const int srcYEnd = blockY + blockH;
|
||||
const int srcXEnd = blockX + validW;
|
||||
|
||||
int dstYStart = (int)((int64_t)blockY * fineScaleFP >> FP_SHIFT);
|
||||
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFP >> FP_SHIFT);
|
||||
int dstXStart = (int)((int64_t)blockX * fineScaleFP >> FP_SHIFT);
|
||||
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFP >> FP_SHIFT);
|
||||
int dstYStart = (int)((int64_t)blockY * fineScaleFPY >> FP_SHIFT);
|
||||
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFPY >> FP_SHIFT);
|
||||
int dstXStart = (int)((int64_t)blockX * fineScaleFPX >> FP_SHIFT);
|
||||
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFPX >> FP_SHIFT);
|
||||
|
||||
// Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks)
|
||||
int clampYMax = ctx->dstHeight;
|
||||
@@ -165,7 +173,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
}
|
||||
|
||||
// === 1:1 fast path: no scaling math ===
|
||||
if (fineScaleFP == FP_ONE) {
|
||||
if (fineScaleFPX == FP_ONE && fineScaleFPY == FP_ONE) {
|
||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||
const int outY = cfgY + dstY;
|
||||
pw.beginRow(outY);
|
||||
@@ -191,11 +199,11 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
// === Bilinear interpolation (upscale: fineScale > 1.0) ===
|
||||
// Smooths block boundaries that would otherwise create visible banding
|
||||
// on progressive JPEG DC-only decode (1/8 resolution upscaled to target).
|
||||
if (fineScaleFP > FP_ONE) {
|
||||
if (fineScaleFPX > FP_ONE && fineScaleFPY > FP_ONE) {
|
||||
// Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1].
|
||||
// Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping.
|
||||
int safeXStart = (int)(((int64_t)blockX * fineScaleFP + FP_MASK) >> FP_SHIFT);
|
||||
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFP >> FP_SHIFT);
|
||||
int safeXStart = (int)(((int64_t)blockX * fineScaleFPX + FP_MASK) >> FP_SHIFT);
|
||||
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFPX >> FP_SHIFT);
|
||||
if (safeXStart < dstXStart) safeXStart = dstXStart;
|
||||
if (safeXEnd > dstXEnd) safeXEnd = dstXEnd;
|
||||
if (safeXStart > safeXEnd) safeXEnd = safeXStart;
|
||||
@@ -204,7 +212,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
const int outY = cfgY + dstY;
|
||||
pw.beginRow(outY);
|
||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||
const int32_t srcFyFP = dstY * invScaleFP;
|
||||
const int32_t srcFyFP = dstY * invScaleFPY;
|
||||
const int32_t fy = srcFyFP & FP_MASK;
|
||||
const int32_t fyInv = FP_ONE - fy;
|
||||
int ly0 = (srcFyFP >> FP_SHIFT) - blockY;
|
||||
@@ -219,7 +227,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
// Left edge (with X boundary clamping)
|
||||
for (int dstX = dstXStart; dstX < safeXStart; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFP;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
const int32_t fx = srcFxFP & FP_MASK;
|
||||
const int32_t fxInv = FP_ONE - fx;
|
||||
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
@@ -247,7 +255,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
// Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds)
|
||||
for (int dstX = safeXStart; dstX < safeXEnd; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFP;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
const int32_t fx = srcFxFP & FP_MASK;
|
||||
const int32_t fxInv = FP_ONE - fx;
|
||||
const int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
@@ -270,7 +278,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
// Right edge (with X boundary clamping)
|
||||
for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFP;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
const int32_t fx = srcFxFP & FP_MASK;
|
||||
const int32_t fxInv = FP_ONE - fx;
|
||||
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
@@ -301,7 +309,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
const int outY = cfgY + dstY;
|
||||
pw.beginRow(outY);
|
||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||
const int32_t srcFyFP = dstY * invScaleFP;
|
||||
const int32_t srcFyFP = dstY * invScaleFPY;
|
||||
int ly = (srcFyFP >> FP_SHIFT) - blockY;
|
||||
if (ly < 0) ly = 0;
|
||||
if (ly >= blockH) ly = blockH - 1;
|
||||
@@ -309,7 +317,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
|
||||
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFP;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
int lx = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
if (lx < 0) lx = 0;
|
||||
if (lx >= validW) lx = validW - 1;
|
||||
@@ -442,12 +450,22 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption);
|
||||
}
|
||||
|
||||
if (destWidth <= 0 || destHeight <= 0) {
|
||||
LOG_ERR("JPG", "Degenerate output dimensions %dx%d for %s, skipping render", destWidth, destHeight,
|
||||
imagePath.c_str());
|
||||
jpeg->close();
|
||||
delete jpeg;
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom;
|
||||
ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom;
|
||||
ctx.dstWidth = destWidth;
|
||||
ctx.dstHeight = destHeight;
|
||||
ctx.fineScaleFP = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
|
||||
ctx.invScaleFP = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
|
||||
ctx.fineScaleFPX = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
|
||||
ctx.invScaleFPX = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
|
||||
ctx.fineScaleFPY = (int32_t)((int64_t)destHeight * FP_ONE / ctx.scaledSrcHeight);
|
||||
ctx.invScaleFPY = (int32_t)((int64_t)ctx.scaledSrcHeight * FP_ONE / destHeight);
|
||||
|
||||
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth,
|
||||
destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "Hyphenator.h"
|
||||
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <vector>
|
||||
@@ -256,7 +258,16 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
|
||||
std::vector<Hyphenator::BreakInfo> breaks;
|
||||
breaks.reserve(indexes.size());
|
||||
for (const size_t idx : indexes) {
|
||||
breaks.push_back({byteOffsetForIndex(cps, idx), true});
|
||||
// CJK characters can break without inserting a visible hyphen.
|
||||
// Check the codepoint at the break position: if it's a CJK character,
|
||||
// no hyphen is needed since CJK scripts don't use hyphenation.
|
||||
bool needsHyphen = true;
|
||||
if (idx < cps.size() && utf8IsCjkBreakable(cps[idx].value)) {
|
||||
needsHyphen = false;
|
||||
} else if (idx > 0 && utf8IsCjkBreakable(cps[idx - 1].value)) {
|
||||
needsHyphen = false;
|
||||
}
|
||||
breaks.push_back({byteOffsetForIndex(cps, idx), needsHyphen});
|
||||
}
|
||||
|
||||
return breaks;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "FsHelpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
@@ -42,6 +43,58 @@ std::string normalisePath(const std::string& path) {
|
||||
return result;
|
||||
}
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs) {
|
||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||
// Directories first
|
||||
bool isDir1 = str1.back() == '/';
|
||||
bool isDir2 = str2.back() == '/';
|
||||
if (isDir1 != isDir2) return isDir1;
|
||||
|
||||
// Start naive natural sort
|
||||
const char* s1 = str1.c_str();
|
||||
const char* s2 = str2.c_str();
|
||||
|
||||
// Iterate while both strings have characters
|
||||
while (*s1 && *s2) {
|
||||
// Check if both are at the start of a number
|
||||
if (isdigit(*s1) && isdigit(*s2)) {
|
||||
// Skip leading zeros and track them
|
||||
const char* start1 = s1;
|
||||
const char* start2 = s2;
|
||||
while (*s1 == '0') s1++;
|
||||
while (*s2 == '0') s2++;
|
||||
|
||||
// Count digits to compare lengths first
|
||||
int len1 = 0, len2 = 0;
|
||||
while (isdigit(s1[len1])) len1++;
|
||||
while (isdigit(s2[len2])) len2++;
|
||||
|
||||
// Different length so return smaller integer value
|
||||
if (len1 != len2) return len1 < len2;
|
||||
|
||||
// Same length so compare digit by digit
|
||||
for (int i = 0; i < len1; i++) {
|
||||
if (s1[i] != s2[i]) return s1[i] < s2[i];
|
||||
}
|
||||
|
||||
// Numbers equal so advance pointers
|
||||
s1 += len1;
|
||||
s2 += len2;
|
||||
} else {
|
||||
// Regular case-insensitive character comparison
|
||||
char c1 = tolower(*s1);
|
||||
char c2 = tolower(*s2);
|
||||
if (c1 != c2) return c1 < c2;
|
||||
s1++;
|
||||
s2++;
|
||||
}
|
||||
}
|
||||
|
||||
// One string is prefix of other
|
||||
return *s1 == '\0' && *s2 != '\0';
|
||||
});
|
||||
}
|
||||
|
||||
bool checkFileExtension(std::string_view fileName, const char* extension) {
|
||||
const size_t extLen = strlen(extension);
|
||||
if (fileName.length() < extLen) {
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace FsHelpers {
|
||||
|
||||
std::string normalisePath(const std::string& path);
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs);
|
||||
|
||||
/**
|
||||
* Check if the given filename ends with the specified extension (case-insensitive).
|
||||
*/
|
||||
|
||||
@@ -2,18 +2,35 @@
|
||||
|
||||
#include <FontDecompressor.h>
|
||||
#include <Logging.h>
|
||||
#include <SdCardFont.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
FontCacheManager::FontCacheManager(const std::map<int, EpdFontFamily>& fontMap) : fontMap_(fontMap) {}
|
||||
FontCacheManager::FontCacheManager(const std::map<int, EpdFontFamily>& fontMap,
|
||||
const std::map<int, SdCardFont*>& sdCardFonts)
|
||||
: fontMap_(fontMap), sdCardFonts_(sdCardFonts) {}
|
||||
|
||||
void FontCacheManager::setFontDecompressor(FontDecompressor* d) { fontDecompressor_ = d; }
|
||||
|
||||
void FontCacheManager::clearCache() {
|
||||
if (fontDecompressor_) fontDecompressor_->clearCache();
|
||||
for (auto& [id, font] : sdCardFonts_) {
|
||||
font->clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask) {
|
||||
// SD card font prewarm path: prewarm all requested styles in one call
|
||||
auto it = sdCardFonts_.find(fontId);
|
||||
if (it != sdCardFonts_.end()) {
|
||||
int missed = it->second->prewarm(utf8Text, styleMask);
|
||||
if (missed > 0) {
|
||||
LOG_DBG("FCM", "prewarmCache(SD): %d glyph(s) not found (styleMask=0x%02X)", missed, styleMask);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard compressed font prewarm path: loop over all requested styles
|
||||
if (!fontDecompressor_ || fontMap_.count(fontId) == 0) return;
|
||||
|
||||
for (uint8_t i = 0; i < 4; i++) {
|
||||
@@ -30,10 +47,16 @@ void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t st
|
||||
|
||||
void FontCacheManager::logStats(const char* label) {
|
||||
if (fontDecompressor_) fontDecompressor_->logStats(label);
|
||||
for (auto& [id, font] : sdCardFonts_) {
|
||||
font->logStats(label);
|
||||
}
|
||||
}
|
||||
|
||||
void FontCacheManager::resetStats() {
|
||||
if (fontDecompressor_) fontDecompressor_->resetStats();
|
||||
for (auto& [id, font] : sdCardFonts_) {
|
||||
font->resetStats();
|
||||
}
|
||||
}
|
||||
|
||||
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
#include <string>
|
||||
|
||||
class FontDecompressor;
|
||||
class SdCardFont;
|
||||
|
||||
class FontCacheManager {
|
||||
public:
|
||||
explicit FontCacheManager(const std::map<int, EpdFontFamily>& fontMap);
|
||||
FontCacheManager(const std::map<int, EpdFontFamily>& fontMap, const std::map<int, SdCardFont*>& sdCardFonts);
|
||||
|
||||
void setFontDecompressor(FontDecompressor* d);
|
||||
|
||||
@@ -45,6 +46,7 @@ class FontCacheManager {
|
||||
|
||||
private:
|
||||
const std::map<int, EpdFontFamily>& fontMap_;
|
||||
const std::map<int, SdCardFont*>& sdCardFonts_;
|
||||
FontDecompressor* fontDecompressor_ = nullptr;
|
||||
|
||||
enum class ScanMode : uint8_t { None, Scanning };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <FontDecompressor.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <Logging.h>
|
||||
#include <SdCardFont.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -22,9 +23,34 @@ const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const Ep
|
||||
// must consume it (draw the glyph) before requesting another bitmap.
|
||||
return fd->getBitmap(fontData, glyph, glyphIndex);
|
||||
}
|
||||
// For SD card fonts, check if the glyph was loaded on demand into the overflow
|
||||
// buffer. getOverflowBitmap() returns:
|
||||
// - bitmap pointer for overflow glyphs with bitmap data
|
||||
// - nullptr for overflow glyphs without bitmap data (e.g. space: width=0, height=0)
|
||||
// - nullptr for non-overflow glyphs (normal prewarmed path)
|
||||
// We distinguish overflow-with-no-bitmap from non-overflow by checking isOverflowGlyph().
|
||||
if (fontData->glyphMissCtx) {
|
||||
auto* sdFont = SdCardFont::fromMissCtx(fontData->glyphMissCtx);
|
||||
if (sdFont->isOverflowGlyph(glyph)) {
|
||||
return sdFont->getOverflowBitmap(glyph); // may be nullptr for zero-width glyphs
|
||||
}
|
||||
}
|
||||
return &fontData->bitmap[glyph->dataOffset];
|
||||
}
|
||||
|
||||
void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask) const {
|
||||
auto it = sdCardFonts_.find(fontId);
|
||||
if (it != sdCardFonts_.end()) {
|
||||
// Augment the persistent advance-only table for layout measurement.
|
||||
// The table survives across paragraphs/sections (capped per font), so
|
||||
// repeated indexing of the same SD font amortizes glyph-metric SD reads.
|
||||
int missed = it->second->buildAdvanceTable(utf8Text, styleMask);
|
||||
if (missed > 0) {
|
||||
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::begin() {
|
||||
frameBuffer = display.getFrameBuffer();
|
||||
if (!frameBuffer) {
|
||||
@@ -38,7 +64,12 @@ void GfxRenderer::begin() {
|
||||
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
||||
}
|
||||
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||
auto result = fontMap.insert({fontId, font});
|
||||
if (!result.second) {
|
||||
LOG_ERR("GFX", "Font ID %d already registered, ignoring duplicate", fontId);
|
||||
}
|
||||
}
|
||||
|
||||
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
|
||||
// This should always be inlined for better performance
|
||||
@@ -1040,6 +1071,12 @@ int GfxRenderer::getScreenHeight() const {
|
||||
}
|
||||
|
||||
int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style style) const {
|
||||
// Advance table fast-path for SD card fonts during layout
|
||||
auto sdIt = sdCardFonts_.find(fontId);
|
||||
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
|
||||
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
@@ -1052,6 +1089,14 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
|
||||
|
||||
int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
|
||||
const EpdFontFamily::Style style) const {
|
||||
// Advance table fast-path for SD card fonts during layout.
|
||||
// Kern data is not loaded during layout (consistent with previous metadataOnly behavior),
|
||||
// so we return just the space advance without kerning.
|
||||
auto sdIt = sdCardFonts_.find(fontId);
|
||||
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
|
||||
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) return 0;
|
||||
const auto& font = fontIt->second;
|
||||
@@ -1073,6 +1118,19 @@ int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint3
|
||||
}
|
||||
|
||||
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
|
||||
// Advance table fast-path for SD card fonts during layout.
|
||||
// No kerning/ligature lookup — consistent with previous metadataOnly behavior
|
||||
// where kern/lig data was not loaded.
|
||||
auto sdIt = sdCardFonts_.find(fontId);
|
||||
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
|
||||
int32_t widthFP = 0;
|
||||
const uint8_t styleIdx = static_cast<uint8_t>(style);
|
||||
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
|
||||
widthFP += sdIt->second->getAdvance(cp, styleIdx);
|
||||
}
|
||||
return fp4::toPixel(widthFP);
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <HalDisplay.h>
|
||||
|
||||
class FontCacheManager;
|
||||
class SdCardFont;
|
||||
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
@@ -42,6 +43,11 @@ class GfxRenderer {
|
||||
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
|
||||
std::vector<uint8_t*> bwBufferChunks;
|
||||
std::map<int, EpdFontFamily> fontMap;
|
||||
// Mutable because ensureSdCardFontReady() is const (called from layout code
|
||||
// that holds a const GfxRenderer&) but triggers SD card reads and heap
|
||||
// allocation inside the SdCardFont objects. Same pragmatic compromise as
|
||||
// fontCacheManager_ below.
|
||||
mutable std::map<int, SdCardFont*> sdCardFonts_;
|
||||
|
||||
// Mutable because drawText() is const but needs to delegate scan-mode
|
||||
// recording to the (non-const) FontCacheManager. Same pragmatic compromise
|
||||
@@ -69,9 +75,25 @@ class GfxRenderer {
|
||||
// Setup
|
||||
void begin(); // must be called right after display.begin()
|
||||
void insertFont(int fontId, EpdFontFamily font);
|
||||
// Clears both the flash-font map and any SD-font registration for fontId.
|
||||
// Coupled to avoid dangling SdCardFont* in sdCardFonts_ when callers free
|
||||
// the underlying SdCardFont and forget the SD-side unregister.
|
||||
void removeFont(int fontId) {
|
||||
fontMap.erase(fontId);
|
||||
sdCardFonts_.erase(fontId);
|
||||
}
|
||||
void setFontCacheManager(FontCacheManager* m) { fontCacheManager_ = m; }
|
||||
FontCacheManager* getFontCacheManager() const { return fontCacheManager_; }
|
||||
const std::map<int, EpdFontFamily>& getFontMap() const { return fontMap; }
|
||||
void registerSdCardFont(int fontId, SdCardFont* font) { sdCardFonts_[fontId] = font; }
|
||||
void unregisterSdCardFont(int fontId) { removeFont(fontId); }
|
||||
void clearSdCardFonts() { sdCardFonts_.clear(); }
|
||||
const std::map<int, SdCardFont*>& getSdCardFonts() const { return sdCardFonts_; }
|
||||
bool isSdCardFont(int fontId) const { return sdCardFonts_.count(fontId) > 0; }
|
||||
// Ensure SD card font glyph data is loaded for the given text. Called from layout code
|
||||
// (which holds a const GfxRenderer&) before measuring word widths. Safe to call on non-SD fonts (no-op).
|
||||
// styleMask: bitmask of styles to prepare (bit 0=regular, 1=bold, 2=italic, 3=bold-italic).
|
||||
void ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask = 0x0F) const;
|
||||
|
||||
// Orientation control (affects logical width/height and coordinate transforms)
|
||||
void setOrientation(const Orientation o) { orientation = o; }
|
||||
|
||||
@@ -229,6 +229,9 @@ STR_EXAMPLE_BOOK: "Book Title"
|
||||
STR_PREVIEW: "Preview"
|
||||
STR_TITLE: "Title"
|
||||
STR_BATTERY: "Battery"
|
||||
STR_XTC_STATUS_BAR: "XTC Status Bar"
|
||||
STR_BOTTOM: "Bottom"
|
||||
STR_TOP: "Top"
|
||||
STR_UI_THEME: "UI Theme"
|
||||
STR_THEME_CLASSIC: "Classic"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
@@ -293,6 +296,7 @@ STR_UPLOAD: "Upload"
|
||||
STR_BOOK_S_STYLE: "Book's Style"
|
||||
STR_EMBEDDED_STYLE: "Embedded Style"
|
||||
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
||||
STR_SET_SLEEP_COVER: "Set Cover"
|
||||
STR_FOOTNOTES: "Footnotes"
|
||||
STR_NO_FOOTNOTES: "No footnotes on this page"
|
||||
STR_LINK: "[link]"
|
||||
@@ -305,6 +309,21 @@ STR_DELETE_CONFIRM: "Delete this server?"
|
||||
STR_OPDS_SERVERS: "OPDS Servers"
|
||||
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
|
||||
STR_DOWNLOAD_FONTS: "Download Fonts"
|
||||
STR_FONT_DOWNLOAD: "Font Download"
|
||||
STR_LOADING_FONT_LIST: "Loading font list..."
|
||||
STR_NO_FONTS_AVAILABLE: "No fonts available"
|
||||
STR_FONT_INSTALLED: "Font installed!"
|
||||
STR_FONT_INSTALL_FAILED: "Font installation failed"
|
||||
STR_INSTALLED: "Installed"
|
||||
STR_CONFIRM_DOWNLOAD_PROMPT: "Download?"
|
||||
STR_SD_CARD_FULL: "Insufficient SD card space"
|
||||
STR_FILES_LABEL: "Files: "
|
||||
STR_SIZE_LABEL: "Size: "
|
||||
STR_REDOWNLOAD: "Re-download"
|
||||
STR_DOWNLOAD_ALL: "Download / Update All"
|
||||
STR_ALL_FONTS_INSTALLED: "All fonts installed!"
|
||||
STR_UPDATE_AVAILABLE: "Update"
|
||||
STR_CRASH_TITLE: "System Crash"
|
||||
STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report."
|
||||
STR_CRASH_REASON: "Crash reason:"
|
||||
|
||||
@@ -171,6 +171,7 @@ STR_NO_UPDATE: "Ingen uppdatering tillgänglig"
|
||||
STR_UPDATE_FAILED: "Uppdatering misslyckades"
|
||||
STR_UPDATE_COMPLETE: "Uppdatering färdig"
|
||||
STR_POWER_ON_HINT: "Tryck och håll strömknappen för att sätta på igen"
|
||||
STR_RESTARTING_HINT: "Startar om... Om enheten inte startar om, håll ner strömknappen i några sekunder."
|
||||
STR_NO_ENTRIES: "Inga poster funna"
|
||||
STR_DOWNLOADING: "Laddar ner…"
|
||||
STR_DOWNLOAD_FAILED: "Nedladdning misslyckades"
|
||||
@@ -324,3 +325,16 @@ STR_KB_HINT_SECONDARY_CHAR: "Håll VÄLJ för sekundärt tecken"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Håll VÄLJ för VERSALER eller sekundärt tecken"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Håll VÄLJ för gemener eller sekundärt tecken"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Tryck på URL för URL-fragment"
|
||||
STR_SD_FIRMWARE_UPDATE: "Uppdatering av firmware från SD-kort"
|
||||
STR_SELECT_FIRMWARE_FILE: "Välj firmwarefil (.bin)"
|
||||
STR_NO_BIN_FILES: "Inga .bin-filer hittades"
|
||||
STR_VALIDATING_FIRMWARE: "Validerar firmware..."
|
||||
STR_INVALID_FIRMWARE: "Ogiltig firmwarefil"
|
||||
STR_FIRMWARE_TOO_LARGE: "Firmware för stor för partitionen"
|
||||
STR_FIRMWARE_TOO_SMALL: "Firmwarefilen är för liten"
|
||||
STR_FIRMWARE_UPDATE_PROMPT: "Uppdatera firmware?"
|
||||
STR_FIRMWARE_FILE_OPEN_FAILED: "Kan inte öppna filen"
|
||||
STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!"
|
||||
STR_RECOVERY_MODE: "Återställningsläge"
|
||||
STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den"
|
||||
|
||||
@@ -26,7 +26,7 @@ STR_LOADING: "Завантаження..."
|
||||
STR_LOADING_POPUP: "Завантаження"
|
||||
STR_WIFI_NETWORKS: "Мережі WiFi"
|
||||
STR_NO_NETWORKS: "Мереж не знайдено"
|
||||
STR_NETWORKS_FOUND: "мереж %zu "
|
||||
STR_NETWORKS_FOUND: "мереж %zu"
|
||||
STR_SCANNING: "Сканування..."
|
||||
STR_CONNECTING: "Підключення..."
|
||||
STR_CONNECTED: "Підключено!"
|
||||
@@ -84,27 +84,27 @@ STR_SCREEN_MARGIN: "Відступи від країв"
|
||||
STR_PARA_ALIGNMENT: "Вирівнювання тексту"
|
||||
STR_HYPHENATION: "Перенесення слів"
|
||||
STR_TIME_TO_SLEEP: "Перехід в режим сну"
|
||||
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
|
||||
STR_REFRESH_FREQ: "Частота оновлення екрану"
|
||||
STR_KOREADER_SYNC: "Синхронізація KOReader"
|
||||
STR_CHECK_UPDATES: "Перевірити оновлення"
|
||||
STR_LANGUAGE: "Мова"
|
||||
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
|
||||
STR_CLEAR_READING_CACHE: "Очистити кеш книг"
|
||||
STR_USERNAME: "Ім'я користувача"
|
||||
STR_PASSWORD: "Пароль"
|
||||
STR_SYNC_SERVER_URL: "URL сервера синхронізації"
|
||||
STR_DOCUMENT_MATCHING: "Зіставлення документів"
|
||||
STR_SYNC_SERVER_URL: "URL для синхронізації"
|
||||
STR_DOCUMENT_MATCHING: "Порівняння документів"
|
||||
STR_AUTHENTICATE: "Автентифікувати"
|
||||
STR_KOREADER_USERNAME: "Ім'я користувача KOReader"
|
||||
STR_KOREADER_PASSWORD: "Пароль KOReader"
|
||||
STR_FILENAME: "Ім'я файлу"
|
||||
STR_BINARY: "Двійковий"
|
||||
STR_SET_CREDENTIALS_FIRST: "Спочатку встановіть облікові дані"
|
||||
STR_BINARY: "Побайтово"
|
||||
STR_SET_CREDENTIALS_FIRST: "Спочатку вкажіть облікові дані"
|
||||
STR_WIFI_CONN_FAILED: "Помилка підключення WiFi"
|
||||
STR_AUTHENTICATING: "Автентифікація..."
|
||||
STR_AUTH_SUCCESS: "Успішно автентифіковано!"
|
||||
STR_KOREADER_AUTH: "Автентифікація KOReader"
|
||||
STR_SYNC_READY: "Синхронізація KOReader готова до використання"
|
||||
STR_SYNC_READY: "Синхронізація KOReader активована"
|
||||
STR_AUTH_FAILED: "Помилка автентифікації"
|
||||
STR_DONE: "Готово"
|
||||
STR_CLEAR_CACHE_WARNING_1: "Це очистить усі кешовані дані книг."
|
||||
@@ -179,6 +179,8 @@ STR_UNNAMED: "Без назви"
|
||||
STR_NO_SERVER_URL: "URL сервера не налаштовано"
|
||||
STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку"
|
||||
STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку"
|
||||
STR_NEXT_PAGE: "Наступна с.»"
|
||||
STR_PREV_PAGE: "« Попередня с."
|
||||
STR_NETWORK_PREFIX: "Мережа: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP адреса: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Помилка: Загальна помилка"
|
||||
@@ -198,14 +200,14 @@ STR_OPEN: "Відкрити"
|
||||
STR_DOWNLOAD: "Завант."
|
||||
STR_RETRY: "Повтор."
|
||||
STR_YES: "Так"
|
||||
STR_NO: "Ні"
|
||||
STR_SHOW: "Показати"
|
||||
STR_HIDE: "Сховати"
|
||||
STR_NO: "Ні"
|
||||
STR_STATE_ON: "УВІМК"
|
||||
STR_STATE_OFF: "ВИМК"
|
||||
STR_NOT_SET: "Не встановлено"
|
||||
STR_DIR_LEFT: "Ліво"
|
||||
STR_DIR_RIGHT: "Право"
|
||||
STR_DIR_LEFT: "Вліво"
|
||||
STR_DIR_RIGHT: "Вправо"
|
||||
STR_DIR_UP: "Вгору"
|
||||
STR_DIR_DOWN: "Вниз"
|
||||
STR_OK_BUTTON: "OK"
|
||||
@@ -229,10 +231,12 @@ STR_BATTERY: "Акумулятор"
|
||||
STR_UI_THEME: "Тема інтерфейсу"
|
||||
STR_THEME_CLASSIC: "Класична"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||
STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці"
|
||||
STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки"
|
||||
STR_OPDS_BROWSER: "Браузер OPDS"
|
||||
STR_SEARCH: "Пошук"
|
||||
STR_COVER_CUSTOM: "Обкл. + власне"
|
||||
STR_MENU_RECENT_BOOKS: "Останні книги"
|
||||
STR_NO_RECENT_BOOKS: "Немає останніх книг"
|
||||
@@ -251,8 +255,8 @@ STR_REMAP_RESET_HINT: "Бічна кнопка Вгору: Скинути до
|
||||
STR_REMAP_CANCEL_HINT: "Бічна кнопка Вниз: Скасувати налаштування"
|
||||
STR_HW_BACK_LABEL: "Назад (1-ша кнопка)"
|
||||
STR_HW_CONFIRM_LABEL: "Підтвердити (2-га кнопка)"
|
||||
STR_HW_LEFT_LABEL: "Ліво (3-тя кнопка)"
|
||||
STR_HW_RIGHT_LABEL: "Право (4-та кнопка)"
|
||||
STR_HW_LEFT_LABEL: "Вліво (3-тя кнопка)"
|
||||
STR_HW_RIGHT_LABEL: "Вправо (4-та кнопка)"
|
||||
STR_GO_TO_PERCENT: "Перейти до %"
|
||||
STR_GO_HOME_BUTTON: "На головну"
|
||||
STR_SYNC_PROGRESS: "Прогрес синхронізації"
|
||||
@@ -263,7 +267,7 @@ STR_CHAPTER_PREFIX: "Розділ: "
|
||||
STR_PAGES_SEPARATOR: " сторінок | "
|
||||
STR_BOOK_PREFIX: "Книга: "
|
||||
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
|
||||
STR_PERCENT_STEP_HINT: "Ліво/Право: 1% Вгору/Вниз: 10%"
|
||||
STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%"
|
||||
STR_SYNCING_TIME: "Синхронізація часу..."
|
||||
STR_CALC_HASH: "Обчислення хешу документа..."
|
||||
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
|
||||
@@ -292,6 +296,31 @@ STR_FOOTNOTES: "Примітки"
|
||||
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
|
||||
STR_LINK: "[посилання]"
|
||||
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
||||
STR_ADD_SERVER: "Додати сервер"
|
||||
STR_SERVER_NAME: "Назва сервера"
|
||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||
STR_DELETE_SERVER: "Видалити сервер"
|
||||
STR_DELETE_CONFIRM: "Видалити цей сервер?"
|
||||
STR_OPDS_SERVERS: "Сервери OPDS"
|
||||
STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)"
|
||||
STR_CRASH_TITLE: "Збій Системи"
|
||||
STR_CRASH_DESCRIPTION: "Дані про збій збережено в crash_report.txt. Додайте цей файл до вашого звіту про помилку."
|
||||
STR_CRASH_REASON: "Причина збою:"
|
||||
STR_CRASH_NO_REASON: "(Причину не вказано)"
|
||||
STR_TILT_PAGE_TURN: "Перегортання нахилом"
|
||||
STR_KB_HINT_MOVE_CURSOR: "Натисніть ВЛІВО / ВПРАВО для переміщення курсору"
|
||||
STR_KB_HINT_RETURN_CURSOR: "Натисніть ВЛІВО для повернення до курсору"
|
||||
STR_KB_HINT_HIDE_PASSWORD: "Затисніть ВПРАВО, натисніть [***], щоб приховати пароль"
|
||||
STR_KB_HINT_SHOW_PASSWORD: "Затисніть ВПРАВО, натисніть [abc], щоб показати пароль"
|
||||
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Натисніть [***], щоб приховати пароль"
|
||||
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Натисніть [abc], щоб показати пароль"
|
||||
STR_KB_HINT_EDIT_ENTRY: "Затисніть ВГОРУ для редагування"
|
||||
STR_KB_TIPS: "Поради:"
|
||||
STR_KB_HINT_RETURN_KEYBOARD: "Натисніть ВНИЗ, щоб повернутися до клавіатури"
|
||||
STR_KB_HINT_EXIT_URL_MODE: "Натисніть [ABC] для виходу з режиму URL"
|
||||
STR_KB_HINT_CLEAR_TEXT: "Затисніть <--, щоб видалити весь текст"
|
||||
STR_KB_HINT_SECONDARY_CHAR: "Затисніть ВИБРАТИ для додаткових символів"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Затисніть ВИБРАТИ для ВЕЛИКИХ літер / символів"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Затисніть ВИБРАТИ для малих літер / символів"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Натисніть URL для вибору шаблонів"
|
||||
|
||||
@@ -15,6 +15,24 @@ void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
// incomplete trailing bytes are excluded.
|
||||
int utf8SafeTruncateBuffer(const char* buf, int len);
|
||||
|
||||
// Returns true for CJK characters that allow line breaks on either side without hyphenation.
|
||||
// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation,
|
||||
// and fullwidth forms — the ranges where word boundaries are implicit per character.
|
||||
inline bool utf8IsCjkBreakable(const uint32_t cp) {
|
||||
return (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation
|
||||
|| (cp >= 0x3040 && cp <= 0x309F) // Hiragana
|
||||
|| (cp >= 0x30A0 && cp <= 0x30FF) // Katakana
|
||||
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK Extension A
|
||||
|| (cp >= 0x4E00 && cp <= 0x9FFF) // CJK Unified Ideographs
|
||||
|| (cp >= 0xAC00 && cp <= 0xD7AF) // Hangul Syllables
|
||||
|| (cp >= 0xF900 && cp <= 0xFAFF) // CJK Compatibility Ideographs
|
||||
|| (cp >= 0xFE30 && cp <= 0xFE4F) // CJK Compatibility Forms
|
||||
|| (cp >= 0xFF01 && cp <= 0xFF60) // Fullwidth Latin / Punctuation
|
||||
|| (cp >= 0xFF65 && cp <= 0xFFEF) // Halfwidth Katakana / Hangul
|
||||
|| (cp >= 0x20000 && cp <= 0x2A6DF) // CJK Extension B
|
||||
|| (cp >= 0x2A700 && cp <= 0x2B73F); // CJK Extension C
|
||||
}
|
||||
|
||||
// Returns true for Unicode combining diacritical marks that should not advance the cursor.
|
||||
inline bool utf8IsCombiningMark(const uint32_t cp) {
|
||||
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build firmware at selected commits and report flash usage.
|
||||
Build firmware at selected commits and report flash and RAM usage.
|
||||
|
||||
Two modes (mutually exclusive, one required):
|
||||
|
||||
@@ -33,6 +33,9 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
RAM_RE = re.compile(
|
||||
r"RAM:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes"
|
||||
)
|
||||
FLASH_RE = re.compile(
|
||||
r"Flash:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes"
|
||||
)
|
||||
@@ -93,9 +96,9 @@ def build_firmware(env):
|
||||
return result.returncode, result.stdout + "\n" + result.stderr
|
||||
|
||||
|
||||
def parse_flash_used(output):
|
||||
"""Extract used-bytes integer from PlatformIO output, or None."""
|
||||
m = FLASH_RE.search(output)
|
||||
def parse_size_line(regex, output):
|
||||
"""Extract used-bytes integer matching *regex* from PlatformIO output, or None."""
|
||||
m = regex.search(output)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
@@ -111,10 +114,10 @@ def write_csv(out, rows, fieldnames):
|
||||
def format_table(rows):
|
||||
"""Print rows as an aligned human-readable table to stdout."""
|
||||
COL_COMMIT = 10
|
||||
COL_FLASH = 11
|
||||
COL_SIZE = 11
|
||||
COL_DELTA = 7
|
||||
|
||||
def fmt_flash(val):
|
||||
def fmt_size(val):
|
||||
if val == "FAILED":
|
||||
return "FAILED"
|
||||
return f"{val:,}"
|
||||
@@ -126,25 +129,33 @@ def format_table(rows):
|
||||
|
||||
header = (
|
||||
f"{'Commit':<{COL_COMMIT}} "
|
||||
f"{'Flash':>{COL_FLASH}} "
|
||||
f"{'Flash':>{COL_SIZE}} "
|
||||
f"{'Delta':>{COL_DELTA}} "
|
||||
f"{'RAM':>{COL_SIZE}} "
|
||||
f"{'Delta':>{COL_DELTA}} "
|
||||
f"Title"
|
||||
)
|
||||
sep = (
|
||||
f"{BOX_CHAR * COL_COMMIT} "
|
||||
f"{BOX_CHAR * COL_FLASH} "
|
||||
f"{BOX_CHAR * COL_SIZE} "
|
||||
f"{BOX_CHAR * COL_DELTA} "
|
||||
f"{BOX_CHAR * COL_SIZE} "
|
||||
f"{BOX_CHAR * COL_DELTA} "
|
||||
f"{BOX_CHAR * 40}"
|
||||
)
|
||||
print(header)
|
||||
print(sep)
|
||||
for row in rows:
|
||||
flash_str = fmt_flash(row["flash_bytes"])
|
||||
delta_str = fmt_delta(row["delta"])
|
||||
flash_str = fmt_size(row["flash_bytes"])
|
||||
flash_d = fmt_delta(row["flash_delta"])
|
||||
ram_str = fmt_size(row["ram_bytes"])
|
||||
ram_d = fmt_delta(row["ram_delta"])
|
||||
print(
|
||||
f"{row['commit']:<{COL_COMMIT}} "
|
||||
f"{flash_str:>{COL_FLASH}} "
|
||||
f"{delta_str:>{COL_DELTA}} "
|
||||
f"{flash_str:>{COL_SIZE}} "
|
||||
f"{flash_d:>{COL_DELTA}} "
|
||||
f"{ram_str:>{COL_SIZE}} "
|
||||
f"{ram_d:>{COL_DELTA}} "
|
||||
f"{row['title']}"
|
||||
)
|
||||
|
||||
@@ -173,7 +184,7 @@ def build_commits_from_list(refs):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Measure firmware flash size across git commits.",
|
||||
description="Measure firmware flash and RAM size across git commits.",
|
||||
epilog=(
|
||||
"Range mode walks every commit between START and END (one branch). "
|
||||
"List mode builds specific refs that may come from different branches."
|
||||
@@ -233,19 +244,22 @@ def main():
|
||||
print(f" Building (env: {args.env})...", file=sys.stderr)
|
||||
rc, output = build_firmware(args.env)
|
||||
|
||||
if rc != 0:
|
||||
build_failed = rc != 0
|
||||
if build_failed:
|
||||
print(f" BUILD FAILED (exit {rc}) -- skipping", file=sys.stderr)
|
||||
results.append((sha, title, None))
|
||||
results.append((sha, title, None, None, True))
|
||||
continue
|
||||
|
||||
used = parse_flash_used(output)
|
||||
if used is None:
|
||||
flash_used = parse_size_line(FLASH_RE, output)
|
||||
ram_used = parse_size_line(RAM_RE, output)
|
||||
if flash_used is None:
|
||||
print(" Could not parse flash size from output -- skipping", file=sys.stderr)
|
||||
results.append((sha, title, None))
|
||||
results.append((sha, title, None, None, True))
|
||||
continue
|
||||
|
||||
print(f" Flash used: {used:,} bytes", file=sys.stderr)
|
||||
results.append((sha, title, used))
|
||||
ram_str = f", RAM: {ram_used:,}" if ram_used is not None else ""
|
||||
print(f" Flash: {flash_used:,}{ram_str} bytes", file=sys.stderr)
|
||||
results.append((sha, title, flash_used, ram_used, False))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[info] Interrupted -- writing partial results.", file=sys.stderr)
|
||||
@@ -258,22 +272,35 @@ def main():
|
||||
|
||||
# Build result rows with deltas
|
||||
rows = []
|
||||
prev_size = None
|
||||
for sha, title, used in results:
|
||||
if used is not None and prev_size is not None:
|
||||
delta = used - prev_size
|
||||
prev_flash = None
|
||||
prev_ram = None
|
||||
for sha, title, flash_used, ram_used, build_failed in results:
|
||||
flash_delta = ""
|
||||
ram_delta = ""
|
||||
if flash_used is not None and prev_flash is not None:
|
||||
flash_delta = flash_used - prev_flash
|
||||
if ram_used is not None and prev_ram is not None:
|
||||
ram_delta = ram_used - prev_ram
|
||||
if build_failed:
|
||||
flash_bytes = "FAILED"
|
||||
ram_bytes = "FAILED"
|
||||
else:
|
||||
delta = ""
|
||||
flash_bytes = flash_used if flash_used is not None else "N/A"
|
||||
ram_bytes = ram_used if ram_used is not None else "N/A"
|
||||
rows.append({
|
||||
"commit": sha[:10],
|
||||
"title": title,
|
||||
"flash_bytes": used if used is not None else "FAILED",
|
||||
"delta": delta,
|
||||
"flash_bytes": flash_bytes,
|
||||
"flash_delta": flash_delta,
|
||||
"ram_bytes": ram_bytes,
|
||||
"ram_delta": ram_delta,
|
||||
})
|
||||
if used is not None:
|
||||
prev_size = used
|
||||
if flash_used is not None:
|
||||
prev_flash = flash_used
|
||||
if ram_used is not None:
|
||||
prev_ram = ram_used
|
||||
|
||||
fieldnames = ["commit", "title", "flash_bytes", "delta"]
|
||||
fieldnames = ["commit", "title", "flash_bytes", "flash_delta", "ram_bytes", "ram_delta"]
|
||||
|
||||
if args.csv is not None:
|
||||
if args.csv == "-":
|
||||
|
||||
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 {
|
||||
// SD card fonts use same compression as Bookerly (the most neutral values)
|
||||
if (sdFontFamilyName[0] != '\0') {
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.95f;
|
||||
case NORMAL:
|
||||
default:
|
||||
return 1.0f;
|
||||
case WIDE:
|
||||
return 1.1f;
|
||||
}
|
||||
}
|
||||
|
||||
switch (fontFamily) {
|
||||
case NOTOSERIF:
|
||||
default:
|
||||
@@ -321,6 +334,13 @@ int CrossPointSettings::getRefreshFrequency() const {
|
||||
}
|
||||
|
||||
int CrossPointSettings::getReaderFontId() const {
|
||||
// Check SD card font first
|
||||
if (sdFontFamilyName[0] != '\0' && sdFontIdResolver) {
|
||||
int id = sdFontIdResolver(sdFontResolverCtx, sdFontFamilyName, fontSize);
|
||||
if (id != 0) return id;
|
||||
// Fall through to built-in if SD font not found
|
||||
}
|
||||
|
||||
switch (fontFamily) {
|
||||
case NOTOSERIF:
|
||||
default:
|
||||
|
||||
@@ -57,6 +57,12 @@ class CrossPointSettings {
|
||||
STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT
|
||||
};
|
||||
enum STATUS_BAR_TITLE { BOOK_TITLE = 0, CHAPTER_TITLE = 1, HIDE_TITLE = 2, STATUS_BAR_TITLE_COUNT };
|
||||
enum XTC_STATUS_BAR_MODE {
|
||||
XTC_STATUS_BAR_HIDE = 0,
|
||||
XTC_STATUS_BAR_BOTTOM = 1,
|
||||
XTC_STATUS_BAR_TOP = 2,
|
||||
XTC_STATUS_BAR_MODE_COUNT
|
||||
};
|
||||
|
||||
enum ORIENTATION {
|
||||
PORTRAIT = 0, // 480x800 logical coordinates (current default)
|
||||
@@ -91,8 +97,9 @@ class CrossPointSettings {
|
||||
// Swapped: Next, Previous
|
||||
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTON_LAYOUT_COUNT };
|
||||
|
||||
// Font family options
|
||||
// Font family options (built-in fonts only; SD card fonts use sdFontFamilyName)
|
||||
enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT };
|
||||
static constexpr uint8_t BUILTIN_FONT_COUNT = FONT_FAMILY_COUNT;
|
||||
// Font size options
|
||||
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT };
|
||||
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT };
|
||||
@@ -161,6 +168,7 @@ class CrossPointSettings {
|
||||
uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL;
|
||||
uint8_t statusBarTitle = CHAPTER_TITLE;
|
||||
uint8_t statusBarBattery = 1;
|
||||
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
|
||||
// Text rendering settings
|
||||
uint8_t extraParagraphSpacing = 1;
|
||||
uint8_t textAntiAliasing = 1;
|
||||
@@ -205,6 +213,8 @@ class CrossPointSettings {
|
||||
uint8_t fadingFix = 0;
|
||||
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
|
||||
uint8_t embeddedStyle = 1;
|
||||
// SD card font family name (empty = use built-in fontFamily)
|
||||
char sdFontFamilyName[32] = "";
|
||||
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
|
||||
uint8_t showHiddenFiles = 0;
|
||||
// Image rendering mode in EPUB reader
|
||||
@@ -219,6 +229,12 @@ class CrossPointSettings {
|
||||
// Get singleton instance
|
||||
static CrossPointSettings& getInstance() { return instance; }
|
||||
|
||||
// Callback to resolve SD card font IDs. Set by SdCardFontSystem::begin().
|
||||
// Returns font ID or 0 if not found.
|
||||
using SdFontIdResolver = int (*)(void* ctx, const char* familyName, uint8_t fontSize);
|
||||
SdFontIdResolver sdFontIdResolver = nullptr;
|
||||
void* sdFontResolverCtx = nullptr;
|
||||
|
||||
uint16_t getPowerButtonDuration() const {
|
||||
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
|
||||
}
|
||||
|
||||
@@ -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["frontButtonLeft"] = s.frontButtonLeft;
|
||||
doc["frontButtonRight"] = s.frontButtonRight;
|
||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||
doc["fontFamily"] = s.fontFamily;
|
||||
// SD card font family name — not in SettingsList, save manually
|
||||
if (s.sdFontFamilyName[0] != '\0') {
|
||||
doc["sdFontFamilyName"] = s.sdFontFamilyName;
|
||||
}
|
||||
|
||||
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||
|
||||
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
@@ -224,6 +234,13 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
||||
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
|
||||
CrossPointSettings::validateFrontButtonMapping(s);
|
||||
|
||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||
s.fontFamily = clamp(doc["fontFamily"] | (uint8_t)0, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
||||
// SD card font family name — not in SettingsList, load manually
|
||||
const char* sfn = doc["sdFontFamilyName"] | "";
|
||||
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
|
||||
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
|
||||
|
||||
// Language -- stored as code string for stability across enum reorders.
|
||||
if (doc["language"].is<const char*>()) {
|
||||
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
+104
-3
@@ -2,18 +2,105 @@
|
||||
|
||||
#include <HalTiltSensor.h>
|
||||
#include <I18n.h>
|
||||
#include <SdCardFontRegistry.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iterator>
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "activities/settings/SettingsActivity.h"
|
||||
|
||||
// Build the font family setting dynamically. When registry is non-null, SD card fonts
|
||||
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
|
||||
inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
||||
// Built-in font labels (StrId)
|
||||
std::vector<StrId> enumValues = {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC};
|
||||
// Runtime string labels for SD card fonts
|
||||
std::vector<std::string> enumStringValues;
|
||||
|
||||
// Reserve: first CrossPointSettings::BUILTIN_FONT_COUNT entries use StrId, rest use strings
|
||||
if (registry) {
|
||||
const auto& families = registry->getFamilies();
|
||||
enumStringValues.reserve(families.size());
|
||||
std::transform(families.begin(), families.end(), std::back_inserter(enumStringValues),
|
||||
[](const SdCardFontFamilyInfo& f) { return f.name; });
|
||||
}
|
||||
|
||||
// Capture the SD font count for the lambdas
|
||||
const int sdFontCount = static_cast<int>(enumStringValues.size());
|
||||
|
||||
// Total option count = built-in + SD card families
|
||||
// For the combined enumStringValues: we need all entries as strings (built-in names + SD names)
|
||||
// The render code checks enumStringValues first, then enumValues. So we build enumStringValues
|
||||
// with all options when SD fonts are present.
|
||||
std::vector<std::string> allStringValues;
|
||||
if (sdFontCount > 0) {
|
||||
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SERIF));
|
||||
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SANS));
|
||||
allStringValues.push_back(I18N.get(StrId::STR_OPEN_DYSLEXIC));
|
||||
allStringValues.insert(allStringValues.end(), enumStringValues.begin(), enumStringValues.end());
|
||||
}
|
||||
|
||||
SettingInfo s;
|
||||
s.nameId = StrId::STR_FONT_FAMILY;
|
||||
s.type = SettingType::ENUM;
|
||||
s.enumValues = std::move(enumValues);
|
||||
s.enumStringValues = std::move(allStringValues);
|
||||
s.key = "fontFamily";
|
||||
s.category = StrId::STR_CAT_READER;
|
||||
|
||||
// Capture registry families by copy for the lambdas
|
||||
std::vector<std::string> sdFamilyNames;
|
||||
if (registry) {
|
||||
const auto& families = registry->getFamilies();
|
||||
sdFamilyNames.reserve(families.size());
|
||||
std::transform(families.begin(), families.end(), std::back_inserter(sdFamilyNames),
|
||||
[](const SdCardFontFamilyInfo& f) { return f.name; });
|
||||
}
|
||||
|
||||
s.valueGetter = [sdFamilyNames]() -> uint8_t {
|
||||
// If an SD card font is selected, find its index
|
||||
if (SETTINGS.sdFontFamilyName[0] != '\0') {
|
||||
for (int i = 0; i < static_cast<int>(sdFamilyNames.size()); i++) {
|
||||
if (sdFamilyNames[i] == SETTINGS.sdFontFamilyName) {
|
||||
return static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i);
|
||||
}
|
||||
}
|
||||
// SD font name not found in registry — fall through to built-in
|
||||
}
|
||||
return SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
||||
};
|
||||
|
||||
s.valueSetter = [sdFamilyNames](uint8_t v) {
|
||||
if (v < CrossPointSettings::BUILTIN_FONT_COUNT) {
|
||||
SETTINGS.fontFamily = v;
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
} else {
|
||||
int sdIdx = v - CrossPointSettings::BUILTIN_FONT_COUNT;
|
||||
if (sdIdx < static_cast<int>(sdFamilyNames.size())) {
|
||||
strncpy(SETTINGS.sdFontFamilyName, sdFamilyNames[sdIdx].c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// Shared settings list used by both the device settings UI and the web settings API.
|
||||
// Each entry has a key (for JSON API) and category (for grouping).
|
||||
// ACTION-type entries and entries without a key are device-only.
|
||||
inline const std::vector<SettingInfo>& getSettingsList() {
|
||||
static const std::vector<SettingInfo> list = [] {
|
||||
//
|
||||
// The static list is constructed exactly once (master's optimization, #1086 +
|
||||
// #1636) so the per-entry SettingInfo cost is paid once. When an
|
||||
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
|
||||
// font-family entry is replaced in a per-call copy with a registry-aware
|
||||
// version. Callers without SD fonts pay only a vector copy.
|
||||
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) {
|
||||
static const std::vector<SettingInfo> baseList = [] {
|
||||
std::vector<SettingInfo> v = {
|
||||
// --- Display ---
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||
@@ -40,6 +127,8 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
|
||||
// --- Reader ---
|
||||
// Built-in font-family entry. Replaced per-call with a registry-aware
|
||||
// version when SD fonts are installed.
|
||||
SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
|
||||
{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
|
||||
StrId::STR_CAT_READER),
|
||||
@@ -78,6 +167,7 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
||||
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
||||
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH},
|
||||
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
||||
|
||||
// --- System ---
|
||||
SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout,
|
||||
{StrId::STR_MIN_1, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15, StrId::STR_MIN_30},
|
||||
@@ -131,6 +221,9 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode,
|
||||
{StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
};
|
||||
// Only show tilt page turn setting when the QMI8658 IMU is present (X3)
|
||||
if (halTiltSensor.isAvailable()) {
|
||||
@@ -146,5 +239,13 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
||||
}
|
||||
return v;
|
||||
}();
|
||||
return list;
|
||||
|
||||
std::vector<SettingInfo> v = baseList;
|
||||
if (registry && registry->getFamilyCount() > 0) {
|
||||
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
|
||||
if (it != v.end()) {
|
||||
*it = buildFontFamilySetting(registry);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
#include <HalPowerManager.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "OpdsServerStore.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "boot_sleep/BootActivity.h"
|
||||
#include "boot_sleep/SleepActivity.h"
|
||||
#include "browser/OpdsBookBrowserActivity.h"
|
||||
@@ -191,6 +194,7 @@ void ActivityManager::goToBrowser() {
|
||||
}
|
||||
|
||||
void ActivityManager::goToReader(std::string path) {
|
||||
ensureSdFontLoaded();
|
||||
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
|
||||
}
|
||||
|
||||
@@ -230,7 +234,11 @@ void ActivityManager::popActivity() {
|
||||
|
||||
bool ActivityManager::preventAutoSleep() const { return currentActivity && currentActivity->preventAutoSleep(); }
|
||||
|
||||
bool ActivityManager::isReaderActivity() const { return currentActivity && currentActivity->isReaderActivity(); }
|
||||
bool ActivityManager::isReaderActivity() const {
|
||||
return std::any_of(stackActivities.begin(), stackActivities.end(),
|
||||
[](const auto& activity) { return activity->isReaderActivity(); }) ||
|
||||
(currentActivity && currentActivity->isReaderActivity());
|
||||
}
|
||||
|
||||
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
|
||||
|
||||
|
||||
@@ -49,6 +49,23 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
// Check if we have a /.sleep (preferred) or /sleep directory
|
||||
const char* sleepDir = nullptr;
|
||||
auto dir = Storage.open("/.sleep");
|
||||
|
||||
// Look for sleep.bmp on the root of the sd card to determine if we should
|
||||
// render a custom sleep screen instead of the default.
|
||||
// This takes priority over the /sleep folder.
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
|
||||
Bitmap bitmap(file, true);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
LOG_DBG("SLP", "Loading: /sleep.bmp");
|
||||
renderBitmapSleepScreen(bitmap);
|
||||
file.close();
|
||||
if (dir) dir.close();
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
|
||||
if (dir && dir.isDirectory()) {
|
||||
sleepDir = "/.sleep";
|
||||
} else {
|
||||
@@ -62,26 +79,31 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
std::vector<std::string> files;
|
||||
char name[500];
|
||||
// collect all valid BMP files
|
||||
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
|
||||
if (file.isDirectory()) {
|
||||
for (auto dirFile = dir.openNextFile(); dirFile; dirFile = dir.openNextFile()) {
|
||||
if (dirFile.isDirectory()) {
|
||||
dirFile.close();
|
||||
continue;
|
||||
}
|
||||
file.getName(name, sizeof(name));
|
||||
dirFile.getName(name, sizeof(name));
|
||||
auto filename = std::string(name);
|
||||
if (filename[0] == '.') {
|
||||
dirFile.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!FsHelpers::hasBmpExtension(filename)) {
|
||||
LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name);
|
||||
dirFile.close();
|
||||
continue;
|
||||
}
|
||||
Bitmap bitmap(file);
|
||||
Bitmap bitmap(dirFile);
|
||||
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
|
||||
LOG_DBG("SLP", "Skipping invalid BMP file: %s", name);
|
||||
dirFile.close();
|
||||
continue;
|
||||
}
|
||||
files.emplace_back(filename);
|
||||
dirFile.close();
|
||||
}
|
||||
const auto numFiles = files.size();
|
||||
if (numFiles > 0) {
|
||||
@@ -97,29 +119,22 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
APP_STATE.pushRecentSleep(randomFileIndex);
|
||||
APP_STATE.saveToFile();
|
||||
const auto filename = std::string(sleepDir) + "/" + files[randomFileIndex];
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("SLP", filename, file)) {
|
||||
FsFile randFile;
|
||||
if (Storage.openFileForRead("SLP", filename, randFile)) {
|
||||
LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str());
|
||||
delay(100);
|
||||
Bitmap bitmap(file, true);
|
||||
Bitmap bitmap(randFile, true);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
renderBitmapSleepScreen(bitmap);
|
||||
randFile.close();
|
||||
dir.close();
|
||||
return;
|
||||
}
|
||||
randFile.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look for sleep.bmp on the root of the sd card to determine if we should
|
||||
// render a custom sleep screen instead of the default.
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
|
||||
Bitmap bitmap(file, true);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
LOG_DBG("SLP", "Loading: /sleep.bmp");
|
||||
renderBitmapSleepScreen(bitmap);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dir) dir.close();
|
||||
|
||||
renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
@@ -18,58 +18,6 @@ namespace {
|
||||
constexpr unsigned long GO_HOME_MS = 1000;
|
||||
} // namespace
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs) {
|
||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||
// Directories first
|
||||
bool isDir1 = str1.back() == '/';
|
||||
bool isDir2 = str2.back() == '/';
|
||||
if (isDir1 != isDir2) return isDir1;
|
||||
|
||||
// Start naive natural sort
|
||||
const char* s1 = str1.c_str();
|
||||
const char* s2 = str2.c_str();
|
||||
|
||||
// Iterate while both strings have characters
|
||||
while (*s1 && *s2) {
|
||||
// Check if both are at the start of a number
|
||||
if (isdigit(*s1) && isdigit(*s2)) {
|
||||
// Skip leading zeros and track them
|
||||
const char* start1 = s1;
|
||||
const char* start2 = s2;
|
||||
while (*s1 == '0') s1++;
|
||||
while (*s2 == '0') s2++;
|
||||
|
||||
// Count digits to compare lengths first
|
||||
int len1 = 0, len2 = 0;
|
||||
while (isdigit(s1[len1])) len1++;
|
||||
while (isdigit(s2[len2])) len2++;
|
||||
|
||||
// Different length so return smaller integer value
|
||||
if (len1 != len2) return len1 < len2;
|
||||
|
||||
// Same length so compare digit by digit
|
||||
for (int i = 0; i < len1; i++) {
|
||||
if (s1[i] != s2[i]) return s1[i] < s2[i];
|
||||
}
|
||||
|
||||
// Numbers equal so advance pointers
|
||||
s1 += len1;
|
||||
s2 += len2;
|
||||
} else {
|
||||
// Regular case-insensitive character comparison
|
||||
char c1 = tolower(*s1);
|
||||
char c2 = tolower(*s2);
|
||||
if (c1 != c2) return c1 < c2;
|
||||
s1++;
|
||||
s2++;
|
||||
}
|
||||
}
|
||||
|
||||
// One string is prefix of other
|
||||
return *s1 == '\0' && *s2 != '\0';
|
||||
});
|
||||
}
|
||||
|
||||
void FileBrowserActivity::loadFiles() {
|
||||
files.clear();
|
||||
|
||||
@@ -103,7 +51,8 @@ void FileBrowserActivity::loadFiles() {
|
||||
}
|
||||
}
|
||||
}
|
||||
sortFileList(files);
|
||||
root.close();
|
||||
FsHelpers::sortFileList(files);
|
||||
}
|
||||
|
||||
void FileBrowserActivity::onEnter() {
|
||||
@@ -190,17 +139,20 @@ void FileBrowserActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) {
|
||||
// --- LONG PRESS ACTION: DELETE FILE ---
|
||||
if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS) {
|
||||
// --- LONG PRESS ACTION: DELETE FILE OR DIRECTORY ---
|
||||
std::string cleanBasePath = basepath;
|
||||
if (cleanBasePath.back() != '/') cleanBasePath += "/";
|
||||
const std::string fullPath = cleanBasePath + entry;
|
||||
|
||||
auto handler = [this, fullPath](const ActivityResult& res) {
|
||||
auto handler = [this, fullPath, isDirectory](const ActivityResult& res) {
|
||||
if (!res.isCancelled) {
|
||||
LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str());
|
||||
clearFileMetadata(fullPath);
|
||||
if (Storage.remove(fullPath.c_str())) {
|
||||
if (!isDirectory) {
|
||||
clearFileMetadata(fullPath);
|
||||
}
|
||||
const bool deleted = isDirectory ? Storage.removeDir(fullPath.c_str()) : Storage.remove(fullPath.c_str());
|
||||
if (deleted) {
|
||||
LOG_DBG("FileBrowser", "Deleted successfully");
|
||||
loadFiles();
|
||||
if (files.empty()) {
|
||||
@@ -212,7 +164,7 @@ void FileBrowserActivity::loop() {
|
||||
|
||||
requestUpdate(true);
|
||||
} else {
|
||||
LOG_ERR("FileBrowser", "Failed to delete file: %s", fullPath.c_str());
|
||||
LOG_ERR("FileBrowser", "Failed to delete: %s", fullPath.c_str());
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("FileBrowser", "Delete cancelled by user");
|
||||
|
||||
@@ -235,7 +235,7 @@ void HomeActivity::render(RenderLock&&) {
|
||||
menuIcons.insert(menuIcons.begin() + 2, Library);
|
||||
}
|
||||
|
||||
if (metrics.homeContinueReadingInMenu) {
|
||||
if (metrics.homeContinueReadingInMenu && !recentBooks.empty()) {
|
||||
// Insert Continue Reading at the top if enabled in theme
|
||||
menuItems.insert(menuItems.begin(), tr(STR_CONTINUE_READING));
|
||||
menuIcons.insert(menuIcons.begin(), Book);
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
|
||||
namespace {
|
||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||
constexpr unsigned long skipChapterMs = 700;
|
||||
// pages per minute, first item is 1 to prevent division by zero if accessed
|
||||
constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
|
||||
|
||||
@@ -185,7 +184,7 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
@@ -203,7 +202,7 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool longPress = !fromTilt && mappedInput.getHeldTime() > skipChapterMs;
|
||||
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
|
||||
|
||||
// Don't skip chapter after screenshot
|
||||
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
|
||||
@@ -583,6 +582,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
SETTINGS.imageRendering)) {
|
||||
LOG_DBG("ERS", "Cache not found, building...");
|
||||
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
|
||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||
|
||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
@@ -746,27 +747,19 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const int orientedMarginRight, const int orientedMarginBottom,
|
||||
const int orientedMarginLeft) {
|
||||
const auto t0 = millis();
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
fcm->resetStats();
|
||||
|
||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||
const uint32_t heapBefore = esp_get_free_heap_size();
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
|
||||
scope.endScanAndPrewarm();
|
||||
const uint32_t heapAfter = esp_get_free_heap_size();
|
||||
fcm->logStats("prewarm");
|
||||
const auto tPrewarm = millis();
|
||||
|
||||
LOG_DBG("ERS", "Heap: before=%lu after=%lu delta=%ld", heapBefore, heapAfter,
|
||||
(int32_t)heapAfter - (int32_t)heapBefore);
|
||||
|
||||
// Force special handling for pages with images when anti-aliasing is on
|
||||
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
||||
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar();
|
||||
fcm->logStats("bw_render");
|
||||
const auto tBwRender = millis();
|
||||
|
||||
if (imagePageWithAA) {
|
||||
@@ -817,8 +810,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
renderer.displayGrayBuffer();
|
||||
const auto tGrayDisplay = millis();
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
fcm->logStats("gray");
|
||||
|
||||
// restore the bw data
|
||||
renderer.restoreBwBuffer();
|
||||
const auto tBwRestore = millis();
|
||||
|
||||
@@ -32,5 +32,4 @@ class EpubReaderChapterSelectionActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ class EpubReaderFootnotesActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
|
||||
private:
|
||||
const std::vector<FootnoteEntry>& footnotes;
|
||||
|
||||
@@ -32,7 +32,6 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
|
||||
private:
|
||||
struct MenuItem {
|
||||
|
||||
@@ -15,7 +15,6 @@ class EpubReaderPercentSelectionActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
|
||||
private:
|
||||
// Current percent value (0-100) shown on the slider.
|
||||
|
||||
@@ -41,7 +41,6 @@ class KOReaderSyncActivity final : public Activity {
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
|
||||
bool isReaderActivity() const override { return true; }
|
||||
|
||||
private:
|
||||
enum State {
|
||||
|
||||
@@ -14,7 +14,6 @@ class QrDisplayActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
|
||||
private:
|
||||
std::string textPayload;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace ReaderUtils {
|
||||
|
||||
constexpr unsigned long GO_HOME_MS = 1000;
|
||||
constexpr unsigned long SKIP_HOLD_MS = 700;
|
||||
|
||||
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
|
||||
switch (orientation) {
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace {
|
||||
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
|
||||
// Cache file magic and version
|
||||
constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI"
|
||||
constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes
|
||||
constexpr uint8_t CACHE_VERSION = 3; // Increment when cache format changes
|
||||
} // namespace
|
||||
|
||||
void TxtReaderActivity::onEnter() {
|
||||
@@ -71,7 +71,7 @@ void TxtReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
@@ -223,8 +223,14 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
|
||||
// Track position within this source line (in bytes from pos)
|
||||
size_t lineBytePos = 0;
|
||||
|
||||
// Word wrap if needed
|
||||
while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||
// Emit at least one visual line for each source line (including blank lines),
|
||||
// then continue with wrapping when needed.
|
||||
do {
|
||||
if (line.empty()) {
|
||||
outLines.emplace_back();
|
||||
break;
|
||||
}
|
||||
|
||||
int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||
|
||||
if (lineWidth <= viewportWidth) {
|
||||
@@ -264,7 +270,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
|
||||
}
|
||||
lineBytePos += skipChars;
|
||||
line = line.substr(skipChars);
|
||||
}
|
||||
} while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage);
|
||||
|
||||
// Determine how much of the source buffer we consumed
|
||||
if (line.empty()) {
|
||||
|
||||
@@ -10,22 +10,19 @@
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "XtcReaderChapterSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr unsigned long skipPageMs = 700;
|
||||
constexpr unsigned long goHomeMs = 1000;
|
||||
} // namespace
|
||||
|
||||
void XtcReaderActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
@@ -70,34 +67,19 @@ void XtcReaderActivity::loop() {
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
activityManager.goToFileBrowser(xtc ? xtc->getPath() : "");
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press BACK goes directly to home
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
|
||||
// When long-press chapter skip is disabled, turn pages on press instead of release.
|
||||
const bool usePressForPageTurn = SETTINGS.longPressButtonBehavior == SETTINGS.OFF;
|
||||
const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward();
|
||||
const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack();
|
||||
const bool prevTriggered =
|
||||
tiltPrev || (usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left))
|
||||
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left)));
|
||||
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Power);
|
||||
const bool nextTriggered =
|
||||
tiltNext || (usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) ||
|
||||
powerPageTurn || mappedInput.wasPressed(MappedInputManager::Button::Right))
|
||||
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
powerPageTurn || mappedInput.wasReleased(MappedInputManager::Button::Right)));
|
||||
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
@@ -113,9 +95,8 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool fromTilt = tiltPrev || tiltNext;
|
||||
const bool skipPages =
|
||||
!fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && mappedInput.getHeldTime() > skipPageMs;
|
||||
const bool skipPages = !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP &&
|
||||
mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
|
||||
const int skipAmount = skipPages ? 10 : 1;
|
||||
|
||||
if (prevTriggered) {
|
||||
@@ -152,6 +133,76 @@ void XtcReaderActivity::render(RenderLock&&) {
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo() const {
|
||||
const int bookPageCount = static_cast<int>(xtc->getPageCount());
|
||||
const int bookPage = static_cast<int>(currentPage) + 1;
|
||||
std::string title =
|
||||
SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE ? xtc->getTitle() : "";
|
||||
|
||||
if (!xtc->hasChapters()) {
|
||||
return StatusBarInfo{bookPage, bookPageCount, std::move(title)};
|
||||
}
|
||||
|
||||
const auto& chapters = xtc->getChapters();
|
||||
const auto chapterIt = std::find_if(chapters.begin(), chapters.end(), [this](const xtc::ChapterInfo& chapter) {
|
||||
return currentPage >= chapter.startPage && currentPage <= chapter.endPage;
|
||||
});
|
||||
|
||||
if (chapterIt == chapters.end() || chapterIt->endPage < chapterIt->startPage) {
|
||||
return StatusBarInfo{bookPage, bookPageCount, std::move(title)};
|
||||
}
|
||||
|
||||
if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) {
|
||||
title = chapterIt->name.empty() ? tr(STR_UNNAMED) : chapterIt->name;
|
||||
}
|
||||
|
||||
return StatusBarInfo{static_cast<int>(currentPage - chapterIt->startPage) + 1,
|
||||
static_cast<int>(chapterIt->endPage - chapterIt->startPage) + 1, std::move(title)};
|
||||
}
|
||||
|
||||
void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition position) const {
|
||||
const bool drawBottom = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_BOTTOM &&
|
||||
position == StatusBarOverlayPosition::Bottom;
|
||||
const bool drawTop = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP &&
|
||||
position == StatusBarOverlayPosition::Top;
|
||||
if (!drawBottom && !drawTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int statusBarHeight = UITheme::getInstance().getStatusBarHeight();
|
||||
if (statusBarHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
&orientedMarginLeft);
|
||||
|
||||
int clearY;
|
||||
int paddingBottom = 0;
|
||||
if (position == StatusBarOverlayPosition::Bottom) {
|
||||
clearY = renderer.getScreenHeight() - orientedMarginBottom - statusBarHeight - 4;
|
||||
if (clearY < 0) {
|
||||
clearY = 0;
|
||||
}
|
||||
} else {
|
||||
clearY = orientedMarginTop;
|
||||
paddingBottom = renderer.getScreenHeight() - statusBarHeight - orientedMarginBottom - orientedMarginTop - 4;
|
||||
}
|
||||
const int clearHeight = position == StatusBarOverlayPosition::Bottom
|
||||
? renderer.getScreenHeight() - orientedMarginBottom - clearY
|
||||
: statusBarHeight + 4;
|
||||
if (clearHeight > 0) {
|
||||
renderer.fillRect(0, clearY, renderer.getScreenWidth(), clearHeight, false);
|
||||
}
|
||||
|
||||
const int pageCount = static_cast<int>(xtc->getPageCount());
|
||||
const int displayPage = static_cast<int>(currentPage) + 1;
|
||||
const float progress = pageCount > 0 ? (static_cast<float>(displayPage) * 100.0f) / pageCount : 0.0f;
|
||||
const auto pageInfo = getStatusBarInfo();
|
||||
GUI.drawStatusBar(renderer, progress, pageInfo.currentPage, pageInfo.pageCount, pageInfo.title, paddingBottom);
|
||||
}
|
||||
|
||||
void XtcReaderActivity::renderPage() {
|
||||
const uint16_t pageWidth = xtc->getPageWidth();
|
||||
const uint16_t pageHeight = xtc->getPageHeight();
|
||||
@@ -242,14 +293,7 @@ void XtcReaderActivity::renderPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else {
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||
|
||||
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
|
||||
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
||||
@@ -319,17 +363,14 @@ void XtcReaderActivity::renderPage() {
|
||||
|
||||
free(pageBuffer);
|
||||
|
||||
// XTC pages already have status bar pre-rendered, no need to add our own
|
||||
|
||||
// Display with appropriate refresh
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
if (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP) {
|
||||
renderStatusBarOverlay(StatusBarOverlayPosition::Top);
|
||||
} else {
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
renderStatusBarOverlay(StatusBarOverlayPosition::Bottom);
|
||||
}
|
||||
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||
|
||||
LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class XtcReaderActivity final : public Activity {
|
||||
@@ -17,7 +20,16 @@ class XtcReaderActivity final : public Activity {
|
||||
uint32_t currentPage = 0;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
|
||||
enum class StatusBarOverlayPosition { Bottom, Top };
|
||||
struct StatusBarInfo {
|
||||
int currentPage;
|
||||
int pageCount;
|
||||
std::string title;
|
||||
};
|
||||
|
||||
void renderPage();
|
||||
void renderStatusBarOverlay(StatusBarOverlayPosition position) const;
|
||||
StatusBarInfo getStatusBarInfo() const;
|
||||
void saveProgress() const;
|
||||
void loadProgress();
|
||||
|
||||
|
||||
@@ -23,5 +23,4 @@ class XtcReaderChapterSelectionActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
};
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
#include "FontDownloadActivity.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
|
||||
FontDownloadActivity::FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("FontDownload", renderer, mappedInput), fontInstaller_(sdFontSystem.registry()) {}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
void FontDownloadActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
WiFi.mode(WIFI_STA);
|
||||
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
|
||||
}
|
||||
|
||||
void FontDownloadActivity::onExit() {
|
||||
Activity::onExit();
|
||||
WiFi.disconnect(false);
|
||||
delay(100);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100);
|
||||
}
|
||||
|
||||
void FontDownloadActivity::onWifiSelectionComplete(const bool success) {
|
||||
if (!success) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = LOADING_MANIFEST;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
if (!fetchAndParseManifest()) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
selectedIndex_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Manifest fetching ---
|
||||
|
||||
bool FontDownloadActivity::fetchAndParseManifest() {
|
||||
// Download manifest to a temp file on SD card to avoid holding both
|
||||
// TLS buffers and the full JSON string in RAM simultaneously.
|
||||
static constexpr const char* MANIFEST_TMP = "/fonts_manifest.tmp";
|
||||
|
||||
auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr);
|
||||
if (result != HttpDownloader::OK) {
|
||||
LOG_ERR("FONT", "Failed to fetch manifest from %s", FONT_MANIFEST_URL);
|
||||
errorMessage_ = "Failed to fetch font list";
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
return false;
|
||||
}
|
||||
|
||||
// HTTP client is now closed — TLS buffers freed. Parse JSON from file.
|
||||
FsFile manifestFile;
|
||||
if (!Storage.openFileForRead("FONT", MANIFEST_TMP, manifestFile)) {
|
||||
LOG_ERR("FONT", "Failed to open temp manifest");
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
errorMessage_ = "Failed to read font list";
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, manifestFile);
|
||||
manifestFile.close();
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
|
||||
if (err) {
|
||||
LOG_ERR("FONT", "Manifest parse error: %s", err.c_str());
|
||||
errorMessage_ = "Invalid font manifest";
|
||||
return false;
|
||||
}
|
||||
|
||||
int version = doc["version"] | 0;
|
||||
if (version != FONTS_MANIFEST_VERSION) {
|
||||
LOG_ERR("FONT", "Unsupported manifest version: %d", version);
|
||||
errorMessage_ = "Unsupported manifest version";
|
||||
return false;
|
||||
}
|
||||
|
||||
baseUrl_ = doc["baseUrl"] | "";
|
||||
families_.clear();
|
||||
|
||||
JsonArray familiesArr = doc["families"].as<JsonArray>();
|
||||
families_.reserve(familiesArr.size());
|
||||
|
||||
for (JsonObject fObj : familiesArr) {
|
||||
ManifestFamily family;
|
||||
family.name = fObj["name"] | "";
|
||||
family.description = fObj["description"] | "";
|
||||
|
||||
for (JsonVariant s : fObj["styles"].as<JsonArray>()) {
|
||||
family.styles.push_back(s.as<std::string>());
|
||||
}
|
||||
|
||||
family.totalSize = 0;
|
||||
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
|
||||
ManifestFile file;
|
||||
file.name = fileObj["name"] | "";
|
||||
file.size = fileObj["size"] | 0;
|
||||
family.totalSize += file.size;
|
||||
family.files.push_back(std::move(file));
|
||||
}
|
||||
|
||||
family.installed = fontInstaller_.isFamilyInstalled(family.name.c_str());
|
||||
|
||||
// Detect updates by comparing manifest file sizes with files on disk.
|
||||
// Not a checksum, but a size mismatch reliably indicates a rebuild in practice.
|
||||
if (family.installed) {
|
||||
for (const auto& file : family.files) {
|
||||
char path[128];
|
||||
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path));
|
||||
FsFile f;
|
||||
if (Storage.openFileForRead("FONT", path, f)) {
|
||||
size_t actual = f.fileSize();
|
||||
f.close();
|
||||
if (actual != file.size) {
|
||||
family.hasUpdate = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// File missing on disk but family dir exists — treat as update
|
||||
family.hasUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
families_.push_back(std::move(family));
|
||||
}
|
||||
|
||||
LOG_DBG("FONT", "Manifest loaded: %zu families", families_.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Download ---
|
||||
|
||||
void FontDownloadActivity::downloadAll() {
|
||||
for (size_t i = 0; i < families_.size(); i++) {
|
||||
if (families_[i].installed && !families_[i].hasUpdate) continue;
|
||||
downloadFamily(families_[i]);
|
||||
if (state_ == ERROR) return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = COMPLETE;
|
||||
}
|
||||
}
|
||||
|
||||
size_t FontDownloadActivity::totalUninstalledSize() const {
|
||||
size_t total = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (!f.installed || f.hasUpdate) total += f.totalSize;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = DOWNLOADING;
|
||||
downloadingFamilyIndex_ = static_cast<int>(&family - families_.data());
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = family.files.size();
|
||||
fileProgress_ = 0;
|
||||
fileTotal_ = 0;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
if (!fontInstaller_.ensureFamilyDir(family.name.c_str())) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = "Failed to create font directory";
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < family.files.size(); i++) {
|
||||
const auto& file = family.files[i];
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
currentFileIndex_ = i;
|
||||
fileProgress_ = 0;
|
||||
fileTotal_ = file.size;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
char destPath[128];
|
||||
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), destPath, sizeof(destPath));
|
||||
|
||||
std::string url = baseUrl_ + file.name;
|
||||
|
||||
auto result = HttpDownloader::downloadToFile(url, destPath, [this](size_t downloaded, size_t total) {
|
||||
fileProgress_ = downloaded;
|
||||
fileTotal_ = total;
|
||||
requestUpdate(true);
|
||||
});
|
||||
|
||||
if (result != HttpDownloader::OK) {
|
||||
LOG_ERR("FONT", "Download failed: %s (%d)", file.name.c_str(), result);
|
||||
fontInstaller_.deleteFamily(family.name.c_str());
|
||||
family.installed = false;
|
||||
family.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = "Download failed: " + file.name;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fontInstaller_.validateCpfontFile(destPath)) {
|
||||
LOG_ERR("FONT", "Invalid .cpfont: %s", destPath);
|
||||
fontInstaller_.deleteFamily(family.name.c_str());
|
||||
family.installed = false;
|
||||
family.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = "Invalid font file: " + file.name;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fontInstaller_.refreshRegistry();
|
||||
family.installed = true;
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = COMPLETE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Input handling ---
|
||||
|
||||
void FontDownloadActivity::loop() {
|
||||
if (state_ == FAMILY_LIST) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this] {
|
||||
if (selectedIndex_ < listItemCount() - 1) {
|
||||
selectedIndex_++;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
buttonNavigator_.onPreviousRelease([this] {
|
||||
if (selectedIndex_ > 0) {
|
||||
selectedIndex_--;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (!families_.empty()) {
|
||||
if (isDownloadAllSelected()) {
|
||||
downloadAll();
|
||||
} else {
|
||||
const auto& family = families_[familyIndexFromList(selectedIndex_)];
|
||||
if (!family.installed || family.hasUpdate) {
|
||||
downloadFamily(families_[familyIndexFromList(selectedIndex_)]);
|
||||
}
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (state_ == COMPLETE) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
} else if (state_ == ERROR) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast<int>(families_.size())) {
|
||||
downloadFamily(families_[downloadingFamilyIndex_]);
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
} else {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
std::string FontDownloadActivity::formatSize(size_t bytes) {
|
||||
char buf[32];
|
||||
if (bytes >= 1024 * 1024) {
|
||||
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
|
||||
} else if (bytes >= 1024) {
|
||||
snprintf(buf, sizeof(buf), "%.0f KB", static_cast<double>(bytes) / 1024.0);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%zu B", bytes);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
void FontDownloadActivity::render(RenderLock&&) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_DOWNLOAD));
|
||||
|
||||
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const auto centerY = (pageHeight - lineHeight) / 2;
|
||||
|
||||
if (state_ == LOADING_MANIFEST) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_LOADING_FONT_LIST));
|
||||
} else if (state_ == FAMILY_LIST) {
|
||||
if (families_.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_NO_FONTS_AVAILABLE));
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
GUI.drawList(
|
||||
renderer,
|
||||
Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing},
|
||||
listItemCount(), selectedIndex_,
|
||||
[this](int index) -> std::string {
|
||||
if (index == 0) {
|
||||
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalUninstalledSize()) + ")";
|
||||
}
|
||||
return families_[familyIndexFromList(index)].name;
|
||||
},
|
||||
nullptr, nullptr,
|
||||
[this](int index) -> std::string {
|
||||
if (index == 0) return "";
|
||||
const auto& f = families_[familyIndexFromList(index)];
|
||||
if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
|
||||
if (f.installed) return tr(STR_INSTALLED);
|
||||
return f.description;
|
||||
},
|
||||
true,
|
||||
[this](int index) -> bool {
|
||||
if (index == 0) return false;
|
||||
const auto& f = families_[familyIndexFromList(index)];
|
||||
// Dim installed fonts, but not those with updates available
|
||||
return f.installed && !f.hasUpdate;
|
||||
});
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
} else if (state_ == DOWNLOADING) {
|
||||
const auto& family = families_[downloadingFamilyIndex_];
|
||||
|
||||
std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + family.name + " (" +
|
||||
std::to_string(currentFileIndex_ + 1) + "/" + std::to_string(currentFileTotal_) + ")";
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, statusText.c_str());
|
||||
|
||||
float progress = 0;
|
||||
if (fileTotal_ > 0) {
|
||||
progress = static_cast<float>(fileProgress_) / static_cast<float>(fileTotal_);
|
||||
}
|
||||
|
||||
int barY = centerY + metrics.verticalSpacing;
|
||||
GUI.drawProgressBar(
|
||||
renderer,
|
||||
Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
|
||||
static_cast<int>(progress * 100), 100);
|
||||
|
||||
int percentY = barY + metrics.progressBarHeight + metrics.verticalSpacing;
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, percentY,
|
||||
(std::to_string(static_cast<int>(progress * 100)) + "%").c_str());
|
||||
} else if (state_ == COMPLETE) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD);
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else if (state_ == ERROR) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_FONT_INSTALL_FAILED), true,
|
||||
EpdFontFamily::BOLD);
|
||||
if (!errorMessage_.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str());
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "FontInstaller.h"
|
||||
#include "SdCardFont.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
// JSON schema version of the fonts.json manifest. The canonical version for
|
||||
// the build tooling lives in lib/EpdFont/scripts/cpfont_version.py. This
|
||||
// firmware-side copy must be bumped manually when the firmware is updated to
|
||||
// support a new manifest schema.
|
||||
#define FONTS_MANIFEST_VERSION 1
|
||||
|
||||
#ifndef FONT_MANIFEST_URL
|
||||
// Manifest + .cpfont assets are published by .github/workflows/release-fonts.yml
|
||||
// to the crosspoint-fonts repo under the "sd-fonts-m<META>-b<BIN>" tag. The tag
|
||||
// pattern must stay in sync with the workflow; it derives its version numbers
|
||||
// from lib/EpdFont/scripts/cpfont_version.py.
|
||||
#define FONT_MANIFEST_URL_STRINGIFY_INNER(x) #x
|
||||
#define FONT_MANIFEST_URL_STRINGIFY(x) FONT_MANIFEST_URL_STRINGIFY_INNER(x)
|
||||
#define FONT_MANIFEST_URL \
|
||||
"https://github.com/crosspoint-reader/crosspoint-fonts/releases/download/sd-fonts-m" FONT_MANIFEST_URL_STRINGIFY( \
|
||||
FONTS_MANIFEST_VERSION) "-b" FONT_MANIFEST_URL_STRINGIFY(CPFONT_VERSION) "/fonts.json"
|
||||
#endif
|
||||
|
||||
class FontDownloadActivity : public Activity {
|
||||
public:
|
||||
explicit FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput);
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool preventAutoSleep() override { return state_ == LOADING_MANIFEST || state_ == DOWNLOADING; }
|
||||
bool skipLoopDelay() override { return true; }
|
||||
|
||||
private:
|
||||
enum State {
|
||||
WIFI_SELECTION,
|
||||
LOADING_MANIFEST,
|
||||
FAMILY_LIST,
|
||||
DOWNLOADING,
|
||||
COMPLETE,
|
||||
ERROR,
|
||||
};
|
||||
|
||||
struct ManifestFile {
|
||||
std::string name;
|
||||
size_t size = 0;
|
||||
};
|
||||
|
||||
struct ManifestFamily {
|
||||
std::string name;
|
||||
std::string description;
|
||||
std::vector<std::string> styles;
|
||||
std::vector<ManifestFile> files;
|
||||
size_t totalSize = 0;
|
||||
bool installed = false;
|
||||
bool hasUpdate = false;
|
||||
};
|
||||
|
||||
State state_ = WIFI_SELECTION;
|
||||
FontInstaller fontInstaller_;
|
||||
ButtonNavigator buttonNavigator_;
|
||||
|
||||
// Manifest data
|
||||
std::string baseUrl_;
|
||||
std::vector<ManifestFamily> families_;
|
||||
int selectedIndex_ = 0;
|
||||
|
||||
// Download progress
|
||||
size_t currentFileIndex_ = 0;
|
||||
size_t currentFileTotal_ = 0;
|
||||
size_t fileProgress_ = 0;
|
||||
size_t fileTotal_ = 0;
|
||||
int downloadingFamilyIndex_ = 0;
|
||||
std::string errorMessage_;
|
||||
|
||||
void onWifiSelectionComplete(bool success);
|
||||
bool fetchAndParseManifest();
|
||||
void downloadFamily(ManifestFamily& family);
|
||||
void downloadAll();
|
||||
bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); }
|
||||
int familyIndexFromList(int listIndex) const { return listIndex - 1; }
|
||||
int listItemCount() const { return families_.empty() ? 0 : static_cast<int>(families_.size()) + 1; }
|
||||
size_t totalUninstalledSize() const;
|
||||
static std::string formatSize(size_t bytes);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "FontSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const SdCardFontRegistry* registry)
|
||||
: Activity("FontSelect", renderer, mappedInput), registry_(registry) {}
|
||||
|
||||
void FontSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Build combined font list: built-in + SD card fonts
|
||||
fonts_.clear();
|
||||
fonts_.reserve(CrossPointSettings::BUILTIN_FONT_COUNT + (registry_ ? registry_->getFamilyCount() : 0));
|
||||
|
||||
fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, 0});
|
||||
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, 1});
|
||||
fonts_.push_back({I18N.get(StrId::STR_OPEN_DYSLEXIC), true, 2});
|
||||
|
||||
if (registry_) {
|
||||
const auto& families = registry_->getFamilies();
|
||||
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||
fonts_.push_back({families[i].name, false, static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i)});
|
||||
}
|
||||
}
|
||||
|
||||
// Find current selection
|
||||
selectedIndex_ = 0;
|
||||
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
|
||||
const auto& families = registry_->getFamilies();
|
||||
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||
if (families[i].name == SETTINGS.sdFontFamilyName) {
|
||||
selectedIndex_ = CrossPointSettings::BUILTIN_FONT_COUNT + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void FontSelectionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void FontSelectionActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this] {
|
||||
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, static_cast<int>(fonts_.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator_.onPreviousRelease([this] {
|
||||
selectedIndex_ = ButtonNavigator::previousIndex(selectedIndex_, static_cast<int>(fonts_.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void FontSelectionActivity::handleSelection() {
|
||||
const auto& font = fonts_[selectedIndex_];
|
||||
if (font.settingIndex < CrossPointSettings::BUILTIN_FONT_COUNT) {
|
||||
SETTINGS.fontFamily = font.settingIndex;
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
} else if (registry_) {
|
||||
int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
|
||||
const auto& families = registry_->getFamilies();
|
||||
if (sdIdx < static_cast<int>(families.size())) {
|
||||
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
finish();
|
||||
}
|
||||
|
||||
void FontSelectionActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_FAMILY));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
// Determine which font index is currently active (to mark as "Selected")
|
||||
int currentFontIndex = 0;
|
||||
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
|
||||
const auto& families = registry_->getFamilies();
|
||||
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||
if (families[i].name == SETTINGS.sdFontFamilyName) {
|
||||
currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentFontIndex = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
||||
}
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
|
||||
[this](int index) { return fonts_[index].name; }, nullptr, nullptr,
|
||||
[this, currentFontIndex](int index) -> std::string { return index == currentFontIndex ? tr(STR_SELECTED) : ""; },
|
||||
true);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <SdCardFontRegistry.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class FontSelectionActivity final : public Activity {
|
||||
public:
|
||||
explicit FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const SdCardFontRegistry* registry);
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
void handleSelection();
|
||||
|
||||
struct FontEntry {
|
||||
std::string name;
|
||||
bool isBuiltin;
|
||||
uint8_t settingIndex; // index used by valueSetter
|
||||
};
|
||||
|
||||
const SdCardFontRegistry* registry_;
|
||||
ButtonNavigator buttonNavigator_;
|
||||
std::vector<FontEntry> fonts_;
|
||||
int selectedIndex_ = 0;
|
||||
};
|
||||
@@ -6,11 +6,14 @@
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "FontDownloadActivity.h"
|
||||
#include "FontSelectionActivity.h"
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerListActivity.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "SdFirmwareUpdateActivity.h"
|
||||
#include "SettingsList.h"
|
||||
#include "StatusBarSettingsActivity.h"
|
||||
@@ -21,16 +24,17 @@
|
||||
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
|
||||
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
|
||||
|
||||
void SettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Build per-category vectors from the shared settings list
|
||||
void SettingsActivity::rebuildSettingsLists() {
|
||||
displaySettings.clear();
|
||||
readerSettings.clear();
|
||||
controlsSettings.clear();
|
||||
systemSettings.clear();
|
||||
|
||||
for (const auto& setting : getSettingsList()) {
|
||||
// Pick up any fonts uploaded/deleted over the web server since the last
|
||||
// reader activity ran — otherwise the font-family picker shows stale list.
|
||||
sdFontSystem.refreshIfDirty();
|
||||
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry())) {
|
||||
if (setting.category == StrId::STR_NONE_OPT) continue;
|
||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||
displaySettings.push_back(setting);
|
||||
@@ -41,7 +45,6 @@ void SettingsActivity::onEnter() {
|
||||
} else if (setting.category == StrId::STR_CAT_SYSTEM) {
|
||||
systemSettings.push_back(setting);
|
||||
}
|
||||
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
|
||||
}
|
||||
|
||||
// Append device-only ACTION items
|
||||
@@ -51,18 +54,41 @@ void SettingsActivity::onEnter() {
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
|
||||
// Insert "Download Fonts" right after the font family setting so users discover it naturally
|
||||
readerSettings.insert(readerSettings.begin() + 1,
|
||||
SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
|
||||
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
|
||||
|
||||
// Update currentSettings pointer and count for the active category
|
||||
switch (selectedCategoryIndex) {
|
||||
case 0:
|
||||
currentSettings = &displaySettings;
|
||||
break;
|
||||
case 1:
|
||||
currentSettings = &readerSettings;
|
||||
break;
|
||||
case 2:
|
||||
currentSettings = &controlsSettings;
|
||||
break;
|
||||
case 3:
|
||||
currentSettings = &systemSettings;
|
||||
break;
|
||||
}
|
||||
settingsCount = static_cast<int>(currentSettings->size());
|
||||
}
|
||||
|
||||
void SettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Reset selection to first category
|
||||
selectedCategoryIndex = 0;
|
||||
selectedSettingIndex = 0;
|
||||
|
||||
// Initialize with first category (Display)
|
||||
currentSettings = &displaySettings;
|
||||
settingsCount = static_cast<int>(displaySettings.size());
|
||||
rebuildSettingsLists();
|
||||
|
||||
// Trigger first update
|
||||
requestUpdate();
|
||||
@@ -159,6 +185,21 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
|
||||
if (setting.nameId == StrId::STR_FONT_FAMILY) {
|
||||
// Launch font selection submenu instead of cycling
|
||||
startActivityForResult(std::make_unique<FontSelectionActivity>(renderer, mappedInput, &sdFontSystem.registry()),
|
||||
[this](const ActivityResult&) {
|
||||
SETTINGS.saveToFile();
|
||||
rebuildSettingsLists();
|
||||
});
|
||||
return;
|
||||
}
|
||||
const uint8_t totalValues = setting.enumStringValues.empty()
|
||||
? static_cast<uint8_t>(setting.enumValues.size())
|
||||
: static_cast<uint8_t>(setting.enumStringValues.size());
|
||||
const uint8_t cur = setting.valueGetter();
|
||||
setting.valueSetter((cur + 1) % totalValues);
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
|
||||
@@ -194,6 +235,13 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
case SettingAction::SdFirmwareUpdate:
|
||||
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
|
||||
break;
|
||||
case SettingAction::DownloadFonts:
|
||||
startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult&) {
|
||||
SETTINGS.saveToFile();
|
||||
rebuildSettingsLists();
|
||||
});
|
||||
break;
|
||||
case SettingAction::Language:
|
||||
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
||||
break;
|
||||
@@ -245,6 +293,13 @@ void SettingsActivity::render(RenderLock&&) {
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t value = SETTINGS.*(setting.valuePtr);
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
|
||||
const uint8_t value = setting.valueGetter();
|
||||
if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
|
||||
valueText = setting.enumStringValues[value];
|
||||
} else if (value < setting.enumValues.size()) {
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
}
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ enum class SettingAction {
|
||||
CheckForUpdates,
|
||||
SdFirmwareUpdate,
|
||||
Language,
|
||||
DownloadFonts,
|
||||
};
|
||||
|
||||
struct SettingInfo {
|
||||
@@ -29,6 +30,7 @@ struct SettingInfo {
|
||||
SettingType type;
|
||||
uint8_t CrossPointSettings::* valuePtr = nullptr;
|
||||
std::vector<StrId> enumValues;
|
||||
std::vector<std::string> enumStringValues; // runtime alternative to StrId enumValues (for SD card fonts etc.)
|
||||
SettingAction action = SettingAction::None;
|
||||
|
||||
struct ValueRange {
|
||||
@@ -159,6 +161,7 @@ class SettingsActivity final : public Activity {
|
||||
|
||||
void enterCategory(int categoryIndex);
|
||||
void toggleCurrentSetting();
|
||||
void rebuildSettingsLists();
|
||||
|
||||
public:
|
||||
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 6;
|
||||
constexpr int MENU_ITEMS = 7;
|
||||
const StrId menuNames[MENU_ITEMS] = {StrId::STR_CHAPTER_PAGE_COUNT,
|
||||
StrId::STR_BOOK_PROGRESS_PERCENTAGE,
|
||||
StrId::STR_PROGRESS_BAR,
|
||||
StrId::STR_PROGRESS_BAR_THICKNESS,
|
||||
StrId::STR_TITLE,
|
||||
StrId::STR_BATTERY};
|
||||
StrId::STR_BATTERY,
|
||||
StrId::STR_XTC_STATUS_BAR};
|
||||
constexpr int PROGRESS_BAR_ITEMS = 3;
|
||||
const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
|
||||
|
||||
@@ -28,6 +29,9 @@ const StrId progressBarThicknessNames[PROGRESS_BAR_THICKNESS_ITEMS] = {
|
||||
constexpr int TITLE_ITEMS = 3;
|
||||
const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
|
||||
|
||||
constexpr int XTC_STATUS_BAR_ITEMS = 3;
|
||||
const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP};
|
||||
|
||||
const int widthMargin = 10;
|
||||
const int verticalPreviewPadding = 50;
|
||||
const int verticalPreviewTextPadding = 40;
|
||||
@@ -51,6 +55,10 @@ void StatusBarSettingsActivity::onEnter() {
|
||||
SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE;
|
||||
}
|
||||
|
||||
if (SETTINGS.xtcStatusBarMode >= XTC_STATUS_BAR_ITEMS) {
|
||||
SETTINGS.xtcStatusBarMode = CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_HIDE;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -110,6 +118,9 @@ void StatusBarSettingsActivity::handleSelection() {
|
||||
} else if (selectedIndex == 5) {
|
||||
// Show Battery
|
||||
SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2;
|
||||
} else if (selectedIndex == 6) {
|
||||
// XTC Status Bar
|
||||
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
|
||||
}
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
@@ -143,6 +154,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
|
||||
return I18N.get(titleNames[SETTINGS.statusBarTitle]);
|
||||
} else if (index == 5) {
|
||||
return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
} else if (index == 6) {
|
||||
return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]);
|
||||
} else {
|
||||
return tr(STR_HIDE);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,67 @@
|
||||
#include "BmpViewerActivity.h"
|
||||
|
||||
#include <Bitmap.h>
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path)
|
||||
: Activity("BmpViewer", renderer, mappedInput), filePath(std::move(path)) {}
|
||||
|
||||
void BmpViewerActivity::loadSiblingImages() {
|
||||
siblingImages.clear();
|
||||
currentImageIndex = -1;
|
||||
|
||||
if (filePath.empty()) return;
|
||||
|
||||
std::string dirPath = FsHelpers::extractFolderPath(filePath);
|
||||
size_t lastSlash = filePath.find_last_of('/');
|
||||
std::string fileName = (lastSlash != std::string::npos) ? filePath.substr(lastSlash + 1) : filePath;
|
||||
|
||||
auto dir = Storage.open(dirPath.c_str());
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
if (dir) dir.close();
|
||||
return;
|
||||
}
|
||||
|
||||
char name[500];
|
||||
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
|
||||
if (!file.isDirectory()) {
|
||||
file.getName(name, sizeof(name));
|
||||
if (name[0] != '.') {
|
||||
std::string fname(name);
|
||||
if (fname.length() >= 4 && fname.substr(fname.length() - 4) == ".bmp") {
|
||||
siblingImages.push_back(fname);
|
||||
}
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
dir.close();
|
||||
|
||||
FsHelpers::sortFileList(siblingImages);
|
||||
|
||||
for (size_t i = 0; i < siblingImages.size(); ++i) {
|
||||
if (siblingImages[i] == fileName) {
|
||||
currentImageIndex = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BmpViewerActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
// Removed the redundant initial renderer.clearScreen()
|
||||
|
||||
if (siblingImages.empty() && !filePath.empty()) {
|
||||
loadSiblingImages();
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
|
||||
@@ -49,7 +97,8 @@ void BmpViewerActivity::onEnter() {
|
||||
}
|
||||
|
||||
// 4. Prepare Rendering
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", "");
|
||||
|
||||
GUI.fillPopupProgress(renderer, popupRect, 50);
|
||||
|
||||
renderer.clearScreen();
|
||||
@@ -61,7 +110,7 @@ void BmpViewerActivity::onEnter() {
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
// Single pass for non-grayscale images
|
||||
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
|
||||
} else {
|
||||
// Handle file parsing error
|
||||
@@ -89,6 +138,39 @@ void BmpViewerActivity::onExit() {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
|
||||
void BmpViewerActivity::doSetSleepCover() {
|
||||
GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
|
||||
|
||||
bool success = false;
|
||||
FsFile inFile, outFile;
|
||||
if (Storage.openFileForRead("BMP", filePath, inFile)) {
|
||||
if (Storage.openFileForWrite("BMP", "/sleep.bmp", outFile)) {
|
||||
char buffer[2048];
|
||||
int bytesRead;
|
||||
success = true;
|
||||
while ((bytesRead = inFile.read(buffer, sizeof(buffer))) > 0) {
|
||||
if (outFile.write(buffer, bytesRead) != bytesRead) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
outFile.close();
|
||||
}
|
||||
inFile.close();
|
||||
}
|
||||
|
||||
if (success) {
|
||||
SETTINGS.sleepScreen = CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM;
|
||||
SETTINGS.saveToFile();
|
||||
GUI.drawPopup(renderer, tr(STR_DONE));
|
||||
} else {
|
||||
GUI.drawPopup(renderer, tr(STR_FAILED_LOWER));
|
||||
}
|
||||
|
||||
delay(1000);
|
||||
onEnter();
|
||||
}
|
||||
|
||||
void BmpViewerActivity::loop() {
|
||||
// Keep CPU awake/polling so 1st click works
|
||||
Activity::loop();
|
||||
@@ -97,4 +179,32 @@ void BmpViewerActivity::loop() {
|
||||
activityManager.goToFileBrowser(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
doSetSleepCover();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
||||
if (siblingImages.size() > 1 && currentImageIndex > 0) {
|
||||
currentImageIndex--;
|
||||
std::string dirPath = FsHelpers::extractFolderPath(filePath);
|
||||
if (dirPath.back() != '/') dirPath += "/";
|
||||
filePath = dirPath + siblingImages[currentImageIndex];
|
||||
onEnter();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||
if (siblingImages.size() > 1 && currentImageIndex != -1 &&
|
||||
currentImageIndex < static_cast<int>(siblingImages.size()) - 1) {
|
||||
currentImageIndex++;
|
||||
std::string dirPath = FsHelpers::extractFolderPath(filePath);
|
||||
if (dirPath.back() != '/') dirPath += "/";
|
||||
filePath = dirPath + siblingImages[currentImageIndex];
|
||||
onEnter();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,10 @@ class BmpViewerActivity final : public Activity {
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
void loadSiblingImages();
|
||||
void doSetSleepCover();
|
||||
|
||||
std::string filePath;
|
||||
std::vector<std::string> siblingImages;
|
||||
int currentImageIndex = -1;
|
||||
};
|
||||
@@ -13,10 +13,6 @@
|
||||
#include "components/themes/lyra/LyraTheme.h"
|
||||
#include "components/themes/roundedraff/RoundedRaffTheme.h"
|
||||
|
||||
namespace {
|
||||
constexpr int SKIP_PAGE_MS = 700;
|
||||
} // namespace
|
||||
|
||||
UITheme UITheme::instance;
|
||||
|
||||
UITheme::UITheme() {
|
||||
|
||||
@@ -20,37 +20,6 @@ constexpr int homeMenuMargin = 20;
|
||||
constexpr int homeMarginTop = 30;
|
||||
constexpr int subtitleY = 738;
|
||||
|
||||
// Helper: draw battery icon at given position
|
||||
void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) {
|
||||
// Draw battery outline (shared code)
|
||||
BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight);
|
||||
|
||||
const bool charging = gpio.isUsbConnected();
|
||||
|
||||
// The +1 is to round up, so that we always fill at least one pixel
|
||||
const int maxFillWidth = battWidth - 5;
|
||||
const int fillHeight = rectHeight - 4;
|
||||
if (maxFillWidth <= 0 || fillHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
int filledWidth = percentage * maxFillWidth / 100 + 1;
|
||||
if (filledWidth > maxFillWidth) {
|
||||
filledWidth = maxFillWidth;
|
||||
}
|
||||
|
||||
// When charging, ensure minimum fill so lightning bolt is fully visible
|
||||
constexpr int minFillForBolt = 8;
|
||||
if (charging && filledWidth < minFillForBolt) {
|
||||
filledWidth = std::min(minFillForBolt, maxFillWidth);
|
||||
}
|
||||
|
||||
renderer.fillRect(x + 2, y + 2, filledWidth, fillHeight);
|
||||
|
||||
// Draw lightning bolt when charging (white/inverted on black fill for visibility)
|
||||
if (charging) {
|
||||
BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
|
||||
@@ -79,6 +48,33 @@ void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX,
|
||||
renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false);
|
||||
}
|
||||
|
||||
void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const {
|
||||
const bool charging = gpio.isUsbConnected();
|
||||
|
||||
const int maxFillWidth = rect.width - 5;
|
||||
const int fillHeight = rect.height - 4;
|
||||
if (maxFillWidth <= 0 || fillHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
// +1 to round up so we always fill at least one pixel
|
||||
int filledWidth = percentage * maxFillWidth / 100 + 1;
|
||||
if (filledWidth > maxFillWidth) {
|
||||
filledWidth = maxFillWidth;
|
||||
}
|
||||
|
||||
// When charging, ensure minimum fill so lightning bolt is fully visible
|
||||
constexpr int minFillForBolt = 8;
|
||||
if (charging && filledWidth < minFillForBolt) {
|
||||
filledWidth = std::min(minFillForBolt, maxFillWidth);
|
||||
}
|
||||
|
||||
renderer.fillRect(rect.x + 2, rect.y + 2, filledWidth, fillHeight);
|
||||
|
||||
if (charging) {
|
||||
drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2);
|
||||
}
|
||||
}
|
||||
|
||||
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Left aligned: icon on left, percentage on right (reader mode)
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
@@ -86,11 +82,12 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
|
||||
|
||||
if (showPercentage) {
|
||||
const auto percentageText = std::to_string(percentage) + "%";
|
||||
renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + BaseMetrics::values.batteryWidth,
|
||||
rect.y, percentageText.c_str());
|
||||
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + rect.width, rect.y, percentageText.c_str());
|
||||
}
|
||||
|
||||
drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage);
|
||||
const Rect iconRect{rect.x, y, rect.width, rect.height};
|
||||
drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height);
|
||||
fillBatteryIcon(renderer, iconRect, percentage);
|
||||
}
|
||||
|
||||
void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
@@ -102,16 +99,12 @@ void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b
|
||||
if (showPercentage) {
|
||||
const auto percentageText = std::to_string(percentage) + "%";
|
||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
||||
// Clear the area where we're going to draw the text to prevent ghosting
|
||||
const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID);
|
||||
renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false);
|
||||
// Draw text to the left of the icon
|
||||
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y,
|
||||
percentageText.c_str());
|
||||
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
|
||||
}
|
||||
|
||||
// Icon is already at correct position from rect.x
|
||||
drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage);
|
||||
const Rect iconRect{rect.x, y, rect.width, rect.height};
|
||||
drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height);
|
||||
fillBatteryIcon(renderer, iconRect, percentage);
|
||||
}
|
||||
|
||||
void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current,
|
||||
@@ -238,7 +231,8 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
const std::function<std::string(int index)>& rowTitle,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<UIIcon(int index)>& rowIcon,
|
||||
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
|
||||
const std::function<std::string(int index)>& rowValue, bool highlightValue,
|
||||
const std::function<bool(int index)>& rowDimmed) const {
|
||||
int rowHeight =
|
||||
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
|
||||
int pageItems = rect.height / rowHeight;
|
||||
@@ -286,6 +280,16 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
|
||||
|
||||
// Apply checkerboard dither to create gray text effect for dimmed items
|
||||
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
|
||||
const int titleWidth = renderer.getTextWidth(font, item.c_str());
|
||||
const int lineH = renderer.getLineHeight(font);
|
||||
const int tx = rect.x + BaseMetrics::values.contentSidePadding;
|
||||
for (int py = itemY; py < itemY + lineH; py++)
|
||||
for (int px = tx; px < tx + titleWidth; px++)
|
||||
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
|
||||
}
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
// Draw subtitle
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
|
||||
@@ -125,11 +125,12 @@ class BaseTheme {
|
||||
virtual ~BaseTheme() = default;
|
||||
|
||||
// Component drawing methods
|
||||
virtual void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const;
|
||||
virtual void drawBatteryLeft(const GfxRenderer& renderer, Rect rect,
|
||||
bool showPercentage = true) const; // Left aligned (reader mode)
|
||||
virtual void drawBatteryRight(const GfxRenderer& renderer, Rect rect,
|
||||
bool showPercentage = true) const; // Right aligned (UI headers)
|
||||
void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const;
|
||||
void drawBatteryLeft(const GfxRenderer& renderer, Rect rect,
|
||||
bool showPercentage = true) const; // Left aligned (reader mode)
|
||||
void drawBatteryRight(const GfxRenderer& renderer, Rect rect,
|
||||
bool showPercentage = true) const; // Right aligned (UI headers)
|
||||
virtual void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const;
|
||||
virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
||||
const char* btn4) const;
|
||||
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const;
|
||||
@@ -137,8 +138,8 @@ class BaseTheme {
|
||||
const std::function<std::string(int index)>& rowTitle,
|
||||
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
||||
const std::function<UIIcon(int index)>& rowIcon = nullptr,
|
||||
const std::function<std::string(int index)>& rowValue = nullptr,
|
||||
bool highlightValue = false) const;
|
||||
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
|
||||
const std::function<bool(int index)>& rowDimmed = nullptr) const;
|
||||
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title,
|
||||
const char* subtitle = nullptr) const;
|
||||
virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
|
||||
@@ -153,10 +154,9 @@ class BaseTheme {
|
||||
const std::function<UIIcon(int index)>& rowIcon) const;
|
||||
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const;
|
||||
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
|
||||
virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
|
||||
const int pageCount, std::string title, const int paddingBottom = 0,
|
||||
const int textYOffset = 0) const;
|
||||
virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
|
||||
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
|
||||
std::string title, const int paddingBottom = 0, const int textYOffset = 0) const;
|
||||
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
|
||||
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
|
||||
int contentStartX = 0, int contentWidth = 0) const;
|
||||
virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected,
|
||||
|
||||
@@ -41,30 +41,6 @@ constexpr int listIconSize = 24;
|
||||
constexpr int mainMenuColumns = 2;
|
||||
int coverWidth = 0;
|
||||
|
||||
void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight,
|
||||
uint16_t percentage) {
|
||||
BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight);
|
||||
|
||||
const bool charging = gpio.isUsbConnected();
|
||||
|
||||
if (charging) {
|
||||
// Draw solid fill when charging so lightning bolt is visible
|
||||
renderer.fillRect(x + 2, y + 2, battWidth - 5, rectHeight - 4);
|
||||
BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2);
|
||||
} else {
|
||||
// Draw bars when not charging
|
||||
if (percentage > 10) {
|
||||
renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4);
|
||||
}
|
||||
if (percentage > 40) {
|
||||
renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4);
|
||||
}
|
||||
if (percentage > 70) {
|
||||
renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uint8_t* iconForName(UIIcon icon, int size) {
|
||||
if (size == 24) {
|
||||
switch (icon) {
|
||||
@@ -107,35 +83,24 @@ const uint8_t* iconForName(UIIcon icon, int size) {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Left aligned: icon on left, percentage on right (reader mode)
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
void LyraTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const {
|
||||
const bool charging = gpio.isUsbConnected();
|
||||
|
||||
if (showPercentage) {
|
||||
const auto percentageText = std::to_string(percentage) + "%";
|
||||
renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + LyraMetrics::values.batteryWidth,
|
||||
rect.y, percentageText.c_str());
|
||||
if (charging) {
|
||||
// Solid fill when charging so lightning bolt is visible
|
||||
renderer.fillRect(rect.x + 2, rect.y + 2, rect.width - 5, rect.height - 4);
|
||||
drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2);
|
||||
} else {
|
||||
if (percentage > 10) {
|
||||
renderer.fillRect(rect.x + 2, rect.y + 2, 3, rect.height - 4);
|
||||
}
|
||||
if (percentage > 40) {
|
||||
renderer.fillRect(rect.x + 6, rect.y + 2, 3, rect.height - 4);
|
||||
}
|
||||
if (percentage > 70) {
|
||||
renderer.fillRect(rect.x + 10, rect.y + 2, 3, rect.height - 4);
|
||||
}
|
||||
}
|
||||
|
||||
drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage);
|
||||
}
|
||||
|
||||
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Right aligned: percentage on left, icon on right (UI headers)
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
|
||||
if (showPercentage) {
|
||||
const auto percentageText = std::to_string(percentage) + "%";
|
||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
||||
// Clear the area where we're going to draw the text to prevent ghosting
|
||||
const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID);
|
||||
renderer.fillRect(rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false);
|
||||
// Draw text to the left of the icon
|
||||
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y,
|
||||
percentageText.c_str());
|
||||
}
|
||||
|
||||
drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage);
|
||||
}
|
||||
|
||||
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const {
|
||||
@@ -243,7 +208,8 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
const std::function<std::string(int index)>& rowTitle,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<UIIcon(int index)>& rowIcon,
|
||||
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
|
||||
const std::function<std::string(int index)>& rowValue, bool highlightValue,
|
||||
const std::function<bool(int index)>& rowDimmed) const {
|
||||
int rowHeight =
|
||||
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
||||
int pageItems = rect.height / rowHeight;
|
||||
@@ -302,6 +268,15 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
|
||||
|
||||
// Apply checkerboard dither to create gray text effect for dimmed items
|
||||
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
|
||||
const int titleWidth = renderer.getTextWidth(UI_10_FONT_ID, item.c_str());
|
||||
const int lineH = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
for (int py = itemY + 7; py < itemY + 7 + lineH; py++)
|
||||
for (int px = textX; px < textX + titleWidth; px++)
|
||||
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
|
||||
}
|
||||
|
||||
if (rowIcon != nullptr) {
|
||||
UIIcon icon = rowIcon(i);
|
||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||
|
||||
@@ -49,9 +49,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
|
||||
class LyraTheme : public BaseTheme {
|
||||
public:
|
||||
// Component drawing methods
|
||||
// void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override;
|
||||
void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override;
|
||||
void drawBatteryRight(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override;
|
||||
void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const override;
|
||||
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override;
|
||||
void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
|
||||
const char* rightLabel = nullptr) const override;
|
||||
@@ -61,7 +59,7 @@ class LyraTheme : public BaseTheme {
|
||||
const std::function<std::string(int index)>& rowTitle,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<UIIcon(int index)>& rowIcon, const std::function<std::string(int index)>& rowValue,
|
||||
bool highlightValue) const override;
|
||||
bool highlightValue, const std::function<bool(int index)>& rowDimmed = nullptr) const override;
|
||||
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
||||
const char* btn4) const override;
|
||||
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "RoundedRaffTheme.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalPowerManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
@@ -22,7 +21,6 @@ constexpr int kBottomRadius = 15;
|
||||
constexpr int kRowRadius = 20;
|
||||
constexpr int kInteractiveInsetX = 20;
|
||||
constexpr int kSelectableRowGap = 6;
|
||||
constexpr int batteryPercentSpacing = 4;
|
||||
constexpr int kTitleFontId = UI_12_FONT_ID; // Requested main title size: 12px
|
||||
constexpr int kSubtitleFontId = SMALL_FONT_ID; // Requested subtitle size: 8px
|
||||
constexpr int kGuideFontId = SMALL_FONT_ID; // Closest available to requested 6px
|
||||
@@ -46,42 +44,6 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa
|
||||
renderer.fillRect(barX, thumbY, barW, thumbH);
|
||||
}
|
||||
|
||||
void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) {
|
||||
// Top line
|
||||
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
|
||||
// Bottom line
|
||||
renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1);
|
||||
// Left line
|
||||
renderer.drawLine(x, y + 1, x, y + rectHeight - 2);
|
||||
// Battery end
|
||||
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2);
|
||||
renderer.drawPixel(x + battWidth - 1, y + 3);
|
||||
renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4);
|
||||
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5);
|
||||
|
||||
// The +1 is to round up, so that we always fill at least one pixel.
|
||||
int filledWidth = percentage * (battWidth - 5) / 100 + 1;
|
||||
if (filledWidth > battWidth - 5) {
|
||||
filledWidth = battWidth - 5; // Ensure we don't overflow.
|
||||
}
|
||||
|
||||
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
|
||||
}
|
||||
|
||||
void drawBatteryRightStable(const GfxRenderer& renderer, Rect iconRect, uint16_t percentage, bool showPercentage) {
|
||||
// Match BaseTheme::drawBatteryRight layout, but use a stable percentage value for this render.
|
||||
const int iconY = iconRect.y + 6;
|
||||
|
||||
if (showPercentage) {
|
||||
const auto percentageText = std::to_string(percentage) + "%";
|
||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
||||
renderer.drawText(SMALL_FONT_ID, iconRect.x - textWidth - batteryPercentSpacing, iconRect.y,
|
||||
percentageText.c_str());
|
||||
}
|
||||
|
||||
drawBatteryIcon(renderer, iconRect.x, iconY, RoundedRaffMetrics::values.batteryWidth, iconRect.height, percentage);
|
||||
}
|
||||
|
||||
std::string sanitizeButtonLabel(std::string label) {
|
||||
// Remove common directional prefixes/symbols (e.g. "<< Home", unsupported icon glyphs).
|
||||
while (!label.empty() && !std::isalnum(static_cast<unsigned char>(label[0]))) {
|
||||
@@ -110,28 +72,27 @@ void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const
|
||||
|
||||
const bool showBatteryPercentage =
|
||||
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int batteryIconX = rect.x + rect.width - sidePadding - RoundedRaffMetrics::values.batteryWidth;
|
||||
|
||||
// Reserve space for the widest possible percentage text to avoid title/battery overlap
|
||||
int batteryGroupLeftX = batteryIconX;
|
||||
if (showBatteryPercentage) {
|
||||
const auto percentageText = std::to_string(percentage) + "%";
|
||||
batteryGroupLeftX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()) + batteryPercentSpacing;
|
||||
|
||||
// Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% ->
|
||||
// 99%).
|
||||
// Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% -> 99%)
|
||||
const int maxTextWidth = renderer.getTextWidth(SMALL_FONT_ID, "100%");
|
||||
batteryGroupLeftX -= maxTextWidth + batteryPercentSpacing;
|
||||
|
||||
const int clearW = maxTextWidth + batteryPercentSpacing + RoundedRaffMetrics::values.batteryWidth;
|
||||
const int clearH = std::max(renderer.getTextHeight(SMALL_FONT_ID), RoundedRaffMetrics::values.batteryHeight + 8);
|
||||
renderer.fillRect(batteryIconX - maxTextWidth - batteryPercentSpacing, rect.y + 14, clearW, clearH, false);
|
||||
}
|
||||
|
||||
const int maxTextWidth = std::max(0, batteryGroupLeftX - 20 - titleX);
|
||||
auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTextWidth, EpdFontFamily::BOLD);
|
||||
const int maxTitleWidth = std::max(0, batteryGroupLeftX - 20 - titleX);
|
||||
auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTitleWidth, EpdFontFamily::BOLD);
|
||||
renderer.drawText(kTitleFontId, titleX, titleY, headerTitle.c_str(), true, EpdFontFamily::BOLD);
|
||||
drawBatteryRightStable(renderer,
|
||||
Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth,
|
||||
RoundedRaffMetrics::values.batteryHeight},
|
||||
percentage, showBatteryPercentage);
|
||||
drawBatteryRight(renderer,
|
||||
Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth,
|
||||
RoundedRaffMetrics::values.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
}
|
||||
|
||||
void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
|
||||
@@ -286,9 +247,11 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item
|
||||
const std::function<std::string(int index)>& rowTitle,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<UIIcon(int index)>& rowIcon,
|
||||
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
|
||||
const std::function<std::string(int index)>& rowValue, bool highlightValue,
|
||||
const std::function<bool(int index)>& rowDimmed) const {
|
||||
(void)rowIcon;
|
||||
(void)highlightValue;
|
||||
(void)rowDimmed;
|
||||
const bool hasSubtitle = static_cast<bool>(rowSubtitle);
|
||||
const int titleLineHeight = renderer.getLineHeight(kTitleFontId);
|
||||
const int subtitleLineHeight = renderer.getLineHeight(kSubtitleFontId);
|
||||
|
||||
@@ -56,8 +56,8 @@ class RoundedRaffTheme : public BaseTheme {
|
||||
const std::function<std::string(int index)>& rowTitle,
|
||||
const std::function<std::string(int index)>& rowSubtitle = nullptr,
|
||||
const std::function<UIIcon(int index)>& rowIcon = nullptr,
|
||||
const std::function<std::string(int index)>& rowValue = nullptr,
|
||||
bool highlightValue = false) const override;
|
||||
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
|
||||
const std::function<bool(int index)>& rowDimmed = nullptr) const override;
|
||||
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
||||
const char* btn4) const override;
|
||||
bool homeMenuShowsContinueReading() const { return true; }
|
||||
|
||||
@@ -16,3 +16,21 @@
|
||||
#define UI_10_FONT_ID (22918846)
|
||||
#define UI_12_FONT_ID (1635686837)
|
||||
#define SMALL_FONT_ID (674098198)
|
||||
|
||||
// Font ID 0 is reserved as the "not found" sentinel.
|
||||
// Guard against any hash accidentally producing 0.
|
||||
static_assert(NOTOSERIF_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(NOTOSERIF_14_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(NOTOSERIF_16_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(NOTOSERIF_18_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(NOTOSANS_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(NOTOSANS_14_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(NOTOSANS_16_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(NOTOSANS_18_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(OPENDYSLEXIC_8_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(OPENDYSLEXIC_10_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(OPENDYSLEXIC_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(OPENDYSLEXIC_14_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(UI_10_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(UI_12_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
static_assert(SMALL_FONT_ID != 0, "Font ID collision with sentinel");
|
||||
|
||||
+20
-2
@@ -22,6 +22,7 @@
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SdCardFontSystem.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "activities/ActivityManager.h"
|
||||
#include "activities/settings/SdFirmwareUpdateActivity.h"
|
||||
@@ -34,7 +35,8 @@ MappedInputManager mappedInputManager(gpio);
|
||||
GfxRenderer renderer(display);
|
||||
ActivityManager activityManager(renderer, mappedInputManager);
|
||||
FontDecompressor fontDecompressor;
|
||||
FontCacheManager fontCacheManager(renderer.getFontMap());
|
||||
SdCardFontSystem sdFontSystem;
|
||||
FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts());
|
||||
|
||||
// Fonts
|
||||
EpdFont notoserif14RegularFont(¬oserif_14_regular);
|
||||
@@ -195,6 +197,8 @@ void enterDeepSleep() {
|
||||
powerManager.startDeepSleep(gpio);
|
||||
}
|
||||
|
||||
void ensureSdFontLoaded() { sdFontSystem.ensureLoaded(renderer); }
|
||||
|
||||
void setupDisplayAndFonts() {
|
||||
display.begin();
|
||||
renderer.begin();
|
||||
@@ -225,6 +229,10 @@ void setupDisplayAndFonts() {
|
||||
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
|
||||
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
|
||||
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
|
||||
|
||||
// Discover and load SD card fonts
|
||||
sdFontSystem.begin(renderer);
|
||||
|
||||
LOG_DBG("MAIN", "Fonts setup");
|
||||
}
|
||||
|
||||
@@ -381,7 +389,9 @@ void loop() {
|
||||
}
|
||||
|
||||
static bool screenshotButtonsReleased = true;
|
||||
static bool screenshotComboActive = false;
|
||||
if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) {
|
||||
screenshotComboActive = true;
|
||||
if (screenshotButtonsReleased) {
|
||||
screenshotButtonsReleased = false;
|
||||
{
|
||||
@@ -390,8 +400,16 @@ void loop() {
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
}
|
||||
if (screenshotComboActive) {
|
||||
if (gpio.isPressed(HalGPIO::BTN_POWER)) return;
|
||||
if (gpio.wasReleased(HalGPIO::BTN_POWER)) {
|
||||
screenshotButtonsReleased = true;
|
||||
screenshotComboActive = false;
|
||||
return;
|
||||
}
|
||||
screenshotButtonsReleased = true;
|
||||
screenshotComboActive = false;
|
||||
}
|
||||
|
||||
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
|
||||
|
||||
@@ -11,10 +11,15 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "FontInstaller.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "SdCardFontSystem.h"
|
||||
#include "SettingsList.h"
|
||||
#include "WebDAVHandler.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
#include "html/FontsPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
#include "html/SettingsPageHtml.generated.h"
|
||||
#include "html/js/jszip_minJs.generated.h"
|
||||
@@ -163,11 +168,22 @@ void CrossPointWebServer::begin() {
|
||||
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
|
||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
||||
|
||||
// Font management endpoints
|
||||
server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); });
|
||||
server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); });
|
||||
server->on("/api/fonts/upload", HTTP_POST, [this] { handleFontUpload(); }, [this] { handleFontUploadData(); });
|
||||
server->on("/api/fonts/delete", HTTP_POST, [this] { handleFontDelete(); });
|
||||
|
||||
// OPDS server endpoints
|
||||
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
||||
|
||||
// Wi-Fi credential endpoints
|
||||
server->on("/api/wifi", HTTP_GET, [this] { handleGetWifiNetworks(); });
|
||||
server->on("/api/wifi", HTTP_POST, [this] { handlePostWifiNetwork(); });
|
||||
server->on("/api/wifi/delete", HTTP_POST, [this] { handleDeleteWifiNetwork(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
@@ -1095,7 +1111,10 @@ void CrossPointWebServer::handleSettingsPage() const {
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleGetSettings() const {
|
||||
const auto& settings = getSettingsList();
|
||||
// Pass the SD font registry so the fontFamily setting's enumStringValues
|
||||
// includes SD-resident families — otherwise the web API only exposes the
|
||||
// three built-in fonts.
|
||||
const auto& settings = getSettingsList(&sdFontSystem.registry());
|
||||
|
||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server->send(200, "application/json", "");
|
||||
@@ -1130,8 +1149,14 @@ void CrossPointWebServer::handleGetSettings() const {
|
||||
doc["value"] = static_cast<int>(s.valueGetter());
|
||||
}
|
||||
JsonArray options = doc["options"].to<JsonArray>();
|
||||
for (const auto& opt : s.enumValues) {
|
||||
options.add(I18N.get(opt));
|
||||
if (!s.enumStringValues.empty()) {
|
||||
for (const auto& opt : s.enumStringValues) {
|
||||
options.add(opt);
|
||||
}
|
||||
} else {
|
||||
for (const auto& opt : s.enumValues) {
|
||||
options.add(I18N.get(opt));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1191,7 +1216,7 @@ void CrossPointWebServer::handlePostSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& settings = getSettingsList();
|
||||
const auto& settings = getSettingsList(&sdFontSystem.registry());
|
||||
int applied = 0;
|
||||
|
||||
for (const auto& s : settings) {
|
||||
@@ -1209,7 +1234,9 @@ void CrossPointWebServer::handlePostSettings() {
|
||||
}
|
||||
case SettingType::ENUM: {
|
||||
const int val = doc[s.key].as<int>();
|
||||
if (val >= 0 && val < static_cast<int>(s.enumValues.size())) {
|
||||
const int maxVal = s.enumStringValues.empty() ? static_cast<int>(s.enumValues.size())
|
||||
: static_cast<int>(s.enumStringValues.size());
|
||||
if (val >= 0 && val < maxVal) {
|
||||
if (s.valuePtr) {
|
||||
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
|
||||
} else if (s.valueSetter) {
|
||||
@@ -1368,6 +1395,140 @@ void CrossPointWebServer::handleDeleteOpdsServer() {
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// ---- Wi-Fi Credentials API ----
|
||||
|
||||
void CrossPointWebServer::handleGetWifiNetworks() const {
|
||||
const auto& credentials = WIFI_STORE.getCredentials();
|
||||
const std::string& lastConnectedSsid = WIFI_STORE.getLastConnectedSsid();
|
||||
|
||||
// Stream JSON array incrementally to avoid allocating the full response in memory
|
||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server->send(200, "application/json", "");
|
||||
server->sendContent("[");
|
||||
|
||||
char output[320];
|
||||
constexpr size_t outputSize = sizeof(output);
|
||||
JsonDocument doc;
|
||||
|
||||
for (size_t i = 0; i < credentials.size(); i++) {
|
||||
doc.clear();
|
||||
doc["index"] = i;
|
||||
doc["ssid"] = credentials[i].ssid;
|
||||
// Never expose Wi-Fi passwords over the API — only indicate whether one is set
|
||||
doc["hasPassword"] = !credentials[i].password.empty();
|
||||
doc["isLastConnected"] = credentials[i].ssid == lastConnectedSsid;
|
||||
|
||||
const size_t written = serializeJson(doc, output, outputSize);
|
||||
if (written >= outputSize) continue;
|
||||
|
||||
if (i > 0) server->sendContent(",");
|
||||
server->sendContent(output);
|
||||
}
|
||||
|
||||
server->sendContent("]");
|
||||
server->sendContent("");
|
||||
LOG_DBG("WEB", "Served Wi-Fi credentials API (%zu network(s))", credentials.size());
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handlePostWifiNetwork() {
|
||||
if (!server->hasArg("plain")) {
|
||||
server->send(400, "text/plain", "Missing JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
const String body = server->arg("plain");
|
||||
JsonDocument doc;
|
||||
const DeserializationError err = deserializeJson(doc, body);
|
||||
if (err) {
|
||||
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
std::string ssid = doc["ssid"] | std::string("");
|
||||
if (ssid.empty()) {
|
||||
server->send(400, "text/plain", "SSID is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// The password field is optional in the JSON payload. When absent (vs. present but empty),
|
||||
// preserve the existing password for updates. Empty passwords are valid for open networks.
|
||||
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
|
||||
std::string password = doc["password"] | std::string("");
|
||||
|
||||
if (doc["index"].is<int>()) {
|
||||
int idx = doc["index"].as<int>();
|
||||
const auto& credentials = WIFI_STORE.getCredentials();
|
||||
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
|
||||
server->send(400, "text/plain", "Invalid network index");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string oldSsid = credentials[static_cast<size_t>(idx)].ssid;
|
||||
if (!hasPasswordField) {
|
||||
password = credentials[static_cast<size_t>(idx)].password;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
if (oldSsid != ssid) {
|
||||
ok = WIFI_STORE.removeCredential(oldSsid) && WIFI_STORE.addCredential(ssid, password);
|
||||
} else {
|
||||
ok = WIFI_STORE.addCredential(ssid, password);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
server->send(400, "text/plain", "Failed to update Wi-Fi network");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("WEB", "Updated Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
|
||||
} else {
|
||||
if (!WIFI_STORE.addCredential(ssid, password)) {
|
||||
server->send(400, "text/plain", "Cannot add network (limit reached)");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Added Wi-Fi network: %s", ssid.c_str());
|
||||
}
|
||||
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
|
||||
void CrossPointWebServer::handleDeleteWifiNetwork() {
|
||||
if (!server->hasArg("plain")) {
|
||||
server->send(400, "text/plain", "Missing JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
const String body = server->arg("plain");
|
||||
JsonDocument doc;
|
||||
const DeserializationError err = deserializeJson(doc, body);
|
||||
if (err) {
|
||||
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc["index"].is<int>()) {
|
||||
server->send(400, "text/plain", "Missing index");
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = doc["index"].as<int>();
|
||||
const auto& credentials = WIFI_STORE.getCredentials();
|
||||
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
|
||||
server->send(400, "text/plain", "Invalid network index");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string ssid = credentials[static_cast<size_t>(idx)].ssid;
|
||||
if (!WIFI_STORE.removeCredential(ssid)) {
|
||||
server->send(400, "text/plain", "Failed to delete Wi-Fi network");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("WEB", "Deleted Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
@@ -1554,3 +1715,199 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Font management handlers ---
|
||||
|
||||
void CrossPointWebServer::handleFontsPage() const {
|
||||
sendHtmlContent(server.get(), FontsPageHtml, sizeof(FontsPageHtml));
|
||||
LOG_DBG("WEB", "Served fonts page");
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleFontList() const {
|
||||
// Pick up any uploads/deletes that happened since the last reader load.
|
||||
const_cast<SdCardFontSystem&>(sdFontSystem).refreshIfDirty();
|
||||
const auto& families = sdFontSystem.registry().getFamilies();
|
||||
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc["families"].to<JsonArray>();
|
||||
doc["maxFamilies"] = SdCardFontRegistry::MAX_SD_FAMILIES;
|
||||
|
||||
for (const auto& family : families) {
|
||||
JsonObject fObj = arr.add<JsonObject>();
|
||||
fObj["name"] = family.name;
|
||||
|
||||
JsonArray sizes = fObj["sizes"].to<JsonArray>();
|
||||
for (uint8_t s : family.availableSizes()) {
|
||||
sizes.add(s);
|
||||
}
|
||||
|
||||
JsonArray files = fObj["files"].to<JsonArray>();
|
||||
for (const auto& file : family.files) {
|
||||
JsonObject fileObj = files.add<JsonObject>();
|
||||
// Extract filename from full path
|
||||
const char* name = strrchr(file.path.c_str(), '/');
|
||||
fileObj["name"] = name ? name + 1 : file.path.c_str();
|
||||
|
||||
// Stat the file for size
|
||||
FsFile f;
|
||||
if (Storage.openFileForRead("WEB", file.path.c_str(), f)) {
|
||||
fileObj["size"] = static_cast<unsigned long>(f.size());
|
||||
f.close();
|
||||
} else {
|
||||
fileObj["size"] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
server->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleFontUploadData() {
|
||||
HTTPUpload& upload = server->upload();
|
||||
|
||||
switch (upload.status) {
|
||||
case UPLOAD_FILE_START: {
|
||||
esp_task_wdt_reset();
|
||||
String family = server->arg("family");
|
||||
fontUpload.valid = false;
|
||||
fontUpload.magicChecked = false;
|
||||
fontUpload.bytesWritten = 0;
|
||||
fontUpload.bufferPos = 0;
|
||||
|
||||
if (!FontInstaller::isValidFamilyName(family.c_str())) {
|
||||
LOG_ERR("WEB", "Invalid font family name: %s", family.c_str());
|
||||
break;
|
||||
}
|
||||
|
||||
String filename = upload.filename;
|
||||
// Validate filename: rejects path traversal (../, /, \) and enforces
|
||||
// a .cpfont basename of alphanumeric + hyphen + underscore. Without
|
||||
// this an attacker could supply "../../.crosspoint/settings.json" as
|
||||
// a "filename" and have it written outside the fonts directory.
|
||||
if (!FontInstaller::isValidCpfontFilename(filename.c_str())) {
|
||||
LOG_ERR("WEB", "Invalid font filename: %s", filename.c_str());
|
||||
break;
|
||||
}
|
||||
|
||||
fontUpload.familyName = family.c_str();
|
||||
|
||||
// Create a temporary FontInstaller for directory creation
|
||||
FontInstaller installer(sdFontSystem.registry());
|
||||
if (!installer.ensureFamilyDir(family.c_str())) {
|
||||
LOG_ERR("WEB", "Failed to create font family dir");
|
||||
break;
|
||||
}
|
||||
|
||||
char path[128];
|
||||
FontInstaller::buildFontPath(family.c_str(), filename.c_str(), path, sizeof(path));
|
||||
fontUpload.filePath = path;
|
||||
|
||||
if (!Storage.openFileForWrite("WEB", path, fontUpload.file)) {
|
||||
LOG_ERR("WEB", "Failed to open font file for write: %s", path);
|
||||
break;
|
||||
}
|
||||
|
||||
fontUpload.valid = true;
|
||||
LOG_DBG("WEB", "Font upload started: %s -> %s", filename.c_str(), path);
|
||||
break;
|
||||
}
|
||||
|
||||
case UPLOAD_FILE_WRITE: {
|
||||
if (!fontUpload.valid) break;
|
||||
esp_task_wdt_reset();
|
||||
|
||||
// Validate magic bytes on first chunk only
|
||||
if (!fontUpload.magicChecked && upload.currentSize >= 8) {
|
||||
if (memcmp(upload.buf, "CPFONT\0\0", 8) != 0) {
|
||||
LOG_ERR("WEB", "Invalid .cpfont magic bytes");
|
||||
fontUpload.valid = false;
|
||||
break;
|
||||
}
|
||||
fontUpload.magicChecked = true;
|
||||
}
|
||||
|
||||
// Buffer writes for efficiency
|
||||
size_t remaining = upload.currentSize;
|
||||
const uint8_t* src = upload.buf;
|
||||
while (remaining > 0) {
|
||||
size_t space = FontUploadState::BUFFER_SIZE - fontUpload.bufferPos;
|
||||
size_t chunk = (remaining < space) ? remaining : space;
|
||||
memcpy(fontUpload.buffer.data() + fontUpload.bufferPos, src, chunk);
|
||||
fontUpload.bufferPos += chunk;
|
||||
src += chunk;
|
||||
remaining -= chunk;
|
||||
|
||||
if (fontUpload.bufferPos >= FontUploadState::BUFFER_SIZE) {
|
||||
fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos);
|
||||
fontUpload.bytesWritten += fontUpload.bufferPos;
|
||||
fontUpload.bufferPos = 0;
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UPLOAD_FILE_END: {
|
||||
// Flush remaining buffer
|
||||
if (fontUpload.valid && fontUpload.bufferPos > 0) {
|
||||
fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos);
|
||||
fontUpload.bytesWritten += fontUpload.bufferPos;
|
||||
fontUpload.bufferPos = 0;
|
||||
}
|
||||
fontUpload.file.close();
|
||||
|
||||
if (!fontUpload.valid && !fontUpload.filePath.empty()) {
|
||||
Storage.remove(fontUpload.filePath.c_str());
|
||||
}
|
||||
|
||||
LOG_DBG("WEB", "Font upload end: valid=%d, %zu bytes", fontUpload.valid, fontUpload.bytesWritten);
|
||||
break;
|
||||
}
|
||||
|
||||
case UPLOAD_FILE_ABORTED: {
|
||||
fontUpload.file.close();
|
||||
if (!fontUpload.filePath.empty()) {
|
||||
Storage.remove(fontUpload.filePath.c_str());
|
||||
}
|
||||
fontUpload.valid = false;
|
||||
LOG_DBG("WEB", "Font upload aborted");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleFontUpload() {
|
||||
if (fontUpload.valid) {
|
||||
sdFontSystem.markRegistryDirty();
|
||||
server->send(200, "application/json", "{\"ok\":true}");
|
||||
LOG_DBG("WEB", "Font upload complete: %s", fontUpload.filePath.c_str());
|
||||
} else {
|
||||
server->send(400, "application/json", "{\"error\":\"Invalid .cpfont file\"}");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleFontDelete() {
|
||||
String body = server->arg("plain");
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, body);
|
||||
|
||||
if (err || !doc["family"].is<const char*>()) {
|
||||
server->send(400, "application/json", "{\"error\":\"Invalid request\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
const char* familyName = doc["family"];
|
||||
FontInstaller installer(sdFontSystem.registry());
|
||||
auto result = installer.deleteFamily(familyName);
|
||||
|
||||
if (result == FontInstaller::Error::OK) {
|
||||
sdFontSystem.markRegistryDirty();
|
||||
server->send(200, "application/json", "{\"ok\":true}");
|
||||
LOG_DBG("WEB", "Deleted font family: %s", familyName);
|
||||
} else {
|
||||
server->send(500, "application/json", "{\"error\":\"Delete failed\"}");
|
||||
LOG_ERR("WEB", "Failed to delete font family: %s", familyName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +108,35 @@ class CrossPointWebServer {
|
||||
void handleGetSettings() const;
|
||||
void handlePostSettings();
|
||||
|
||||
// Font management handlers
|
||||
void handleFontsPage() const;
|
||||
void handleFontList() const;
|
||||
void handleFontUpload();
|
||||
void handleFontUploadData();
|
||||
void handleFontDelete();
|
||||
|
||||
// Font upload state
|
||||
struct FontUploadState {
|
||||
FsFile file;
|
||||
std::string familyName;
|
||||
std::string filePath;
|
||||
bool valid = false;
|
||||
bool magicChecked = false;
|
||||
size_t bytesWritten = 0;
|
||||
static constexpr size_t BUFFER_SIZE = 4096;
|
||||
std::vector<uint8_t> buffer;
|
||||
size_t bufferPos = 0;
|
||||
|
||||
FontUploadState() { buffer.resize(BUFFER_SIZE); }
|
||||
} fontUpload;
|
||||
|
||||
// OPDS server handlers
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
|
||||
// Wi-Fi credential handlers
|
||||
void handleGetWifiNetworks() const;
|
||||
void handlePostWifiNetwork();
|
||||
void handleDeleteWifiNetwork();
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
h2 {
|
||||
color: var(--title-color);
|
||||
margin-top: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
.page-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1465,6 +1465,7 @@
|
||||
<a href="/">Home</a>
|
||||
<a href="/files" class="active">File Manager</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/fonts">Fonts</a>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
|
||||
@@ -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="/files">File Manager</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/fonts">Fonts</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
<a href="/">Home</a>
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/settings" class="active">Settings</a>
|
||||
<a href="/fonts">Fonts</a>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
@@ -299,6 +300,7 @@
|
||||
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="wifi-container"></div>
|
||||
<div id="opds-container"></div>
|
||||
|
||||
<div class="card">
|
||||
@@ -480,6 +482,119 @@
|
||||
|
||||
loadSettings();
|
||||
|
||||
// --- Wi-Fi Network Management ---
|
||||
// Renders an editable list of saved Wi-Fi networks using /api/wifi endpoints.
|
||||
// Password fields are never pre-filled; when left blank during edit, existing
|
||||
// passwords remain unchanged server-side.
|
||||
let wifiNetworks = [];
|
||||
|
||||
function renderWifiNetwork(net, idx) {
|
||||
const isNew = idx === -1;
|
||||
const id = isNew ? 'new' : idx;
|
||||
const lastConnected = net.isLastConnected
|
||||
? '<div style="margin-top:8px;color:var(--label-color);font-size:0.9em;">Last connected network</div>'
|
||||
: '';
|
||||
|
||||
return '<div class="opds-server" id="wifi-' + id + '">' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">SSID</span>' +
|
||||
'<span class="setting-control"><input type="text" id="wifi-ssid-' + id + '" value="' + escapeHtml(net.ssid || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Password</span>' +
|
||||
'<span class="setting-control"><input type="password" id="wifi-pass-' + id + '" placeholder="' + (net.hasPassword ? '(unchanged)' : '') + '"></span>' +
|
||||
'</div>' +
|
||||
lastConnected +
|
||||
'<div class="opds-actions">' +
|
||||
'<button class="btn-small btn-save-server" onclick="saveWifiNetwork(' + idx + ')">Save</button>' +
|
||||
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteWifiNetwork(' + idx + ')">Delete</button>') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderWifiSection() {
|
||||
const container = document.getElementById('wifi-container');
|
||||
let html = '<div class="card"><h2>Wi-Fi Networks</h2>';
|
||||
|
||||
if (wifiNetworks.length === 0) {
|
||||
html += '<p style="color:var(--label-color);text-align:center;">No Wi-Fi networks saved</p>';
|
||||
} else {
|
||||
wifiNetworks.forEach(function(net, idx) {
|
||||
html += renderWifiNetwork(net, idx);
|
||||
});
|
||||
}
|
||||
|
||||
html += '<div style="margin-top:12px;text-align:center;">' +
|
||||
'<button class="btn-small btn-add" onclick="addWifiNetwork()">+ Add Network</button>' +
|
||||
'</div></div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadWifiNetworks() {
|
||||
try {
|
||||
const resp = await fetch('/api/wifi');
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
wifiNetworks = await resp.json();
|
||||
renderWifiSection();
|
||||
} catch (e) {
|
||||
console.error('Wi-Fi load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function addWifiNetwork() {
|
||||
const container = document.getElementById('wifi-container');
|
||||
const card = container.querySelector('.card');
|
||||
const addBtn = card.querySelector('.btn-add').parentElement;
|
||||
// Prevent multiple unsaved new-network forms at once (idx -1 -> id "new")
|
||||
if (document.getElementById('wifi-new')) return;
|
||||
addBtn.insertAdjacentHTML('beforebegin', renderWifiNetwork({ssid:'',hasPassword:false,isLastConnected:false}, -1));
|
||||
}
|
||||
|
||||
async function saveWifiNetwork(idx) {
|
||||
const id = idx === -1 ? 'new' : idx;
|
||||
const ssid = document.getElementById('wifi-ssid-' + id).value.trim();
|
||||
if (!ssid) {
|
||||
showMessage('SSID is required.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = { ssid: ssid };
|
||||
// Only include password when the user actually typed something; omitting it
|
||||
// tells the server to preserve an existing password.
|
||||
const pass = document.getElementById('wifi-pass-' + id).value;
|
||||
if (pass) data.password = pass;
|
||||
if (idx >= 0) data.index = idx;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/wifi', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showMessage('Wi-Fi network saved!', false);
|
||||
await loadWifiNetworks();
|
||||
} catch (e) {
|
||||
showMessage('Error: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWifiNetwork(idx) {
|
||||
if (!confirm('Delete this Wi-Fi network?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/wifi/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({index: idx})
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showMessage('Wi-Fi network deleted', false);
|
||||
await loadWifiNetworks();
|
||||
} catch (e) {
|
||||
showMessage('Error: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- OPDS Server Management ---
|
||||
// Dynamically renders an editable list of OPDS servers, communicating with the
|
||||
// /api/opds REST endpoints. Password fields are never pre-filled for security;
|
||||
@@ -594,6 +709,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
loadWifiNetworks();
|
||||
loadOpdsServers();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user