feat: add SD card font support with on-device download and web management

Add a complete SD card font subsystem that enables users to install and
use custom fonts beyond the three built-in families. This combines the
back-end firmware support (#1327) with the font configuration, build
pipeline, CI distribution, and user-facing management UI (#1392).

Core font system:
- Custom .cpfont binary format (v4) with multi-style support (regular,
  bold, italic, bold-italic) packed into a single file per size
- On-demand glyph loading from SD card with two-pass prewarm rendering
  to bulk-read glyphs per page, achieving near-flash performance for
  Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower)
- Persistent advance cache for layout measurement without SD I/O
- Overflow ring buffer for glyph cache misses during rendering
- Memory-conscious design: only advance tables kept in RAM; glyph
  bitmaps, kern tables, and ligatures loaded on demand from SD

Font management:
- On-device WiFi download from GitHub Releases with manifest-based
  discovery, install/update detection, and progress UI
- Web interface font upload, listing, and deletion via /fonts page
- Manual SD card copy to /fonts/ or /.fonts/ directories
- Font selection integrated into Settings > Reader > Font Family

Build pipeline:
- Declarative YAML config (sd-fonts.yaml) as single source of truth
  for the 17-family font library (serif, sans, mono, accessibility)
- Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with
  FreeType rasterization, class-based kerning, and ligature extraction
- Parallel build orchestrator with variable font instance extraction
- CI workflow publishing versioned + stable releases to a dedicated
  crosspoint-fonts repository with auto-incrementing revision tags
- Centralized version constants (cpfont_version.py) shared across
  build tooling and CI, with firmware headers as manual sync points

Additional fixes:
- CJK characters no longer get hyphens inserted at line breaks
- Advance table eliminates 30+ second stalls during CJK section
  indexing for paragraphs with >512 unique codepoints

Closes #930

Co-authored-by: Zach Nelson <zach@zdnelson.com>
Co-authored-by: Justin <itsthisjustin@users.noreply.github.com>
Co-authored-by: jpirnay <jens@pirnay.com>
Co-authored-by: mcrosson <kemonine@kemonine.info>
This commit is contained in:
Adrian Wilkins-Caruana
2026-05-08 21:50:06 -05:00
committed by Zach Nelson
co-authored by Zach Nelson Justin jpirnay mcrosson
parent 29fd29f537
commit 7993b2bb97
57 changed files with 6064 additions and 54 deletions
+20
View File
@@ -253,6 +253,19 @@ bool CrossPointSettings::loadFromBinaryFile() {
}
float CrossPointSettings::getReaderLineCompression() const {
// SD card fonts use same compression as Bookerly (the most neutral values)
if (sdFontFamilyName[0] != '\0') {
switch (lineSpacing) {
case TIGHT:
return 0.95f;
case NORMAL:
default:
return 1.0f;
case WIDE:
return 1.1f;
}
}
switch (fontFamily) {
case NOTOSERIF:
default:
@@ -321,6 +334,13 @@ int CrossPointSettings::getRefreshFrequency() const {
}
int CrossPointSettings::getReaderFontId() const {
// Check SD card font first
if (sdFontFamilyName[0] != '\0' && sdFontIdResolver) {
int id = sdFontIdResolver(sdFontResolverCtx, sdFontFamilyName, fontSize);
if (id != 0) return id;
// Fall through to built-in if SD font not found
}
switch (fontFamily) {
case NOTOSERIF:
default:
+10 -1
View File
@@ -97,8 +97,9 @@ class CrossPointSettings {
// Swapped: Next, Previous
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTON_LAYOUT_COUNT };
// Font family options
// Font family options (built-in fonts only; SD card fonts use sdFontFamilyName)
enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT };
static constexpr uint8_t BUILTIN_FONT_COUNT = FONT_FAMILY_COUNT;
// Font size options
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT };
@@ -212,6 +213,8 @@ class CrossPointSettings {
uint8_t fadingFix = 0;
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
uint8_t embeddedStyle = 1;
// SD card font family name (empty = use built-in fontFamily)
char sdFontFamilyName[32] = "";
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
uint8_t showHiddenFiles = 0;
// Image rendering mode in EPUB reader
@@ -226,6 +229,12 @@ class CrossPointSettings {
// Get singleton instance
static CrossPointSettings& getInstance() { return instance; }
// Callback to resolve SD card font IDs. Set by SdCardFontSystem::begin().
// Returns font ID or 0 if not found.
using SdFontIdResolver = int (*)(void* ctx, const char* familyName, uint8_t fontSize);
SdFontIdResolver sdFontIdResolver = nullptr;
void* sdFontResolverCtx = nullptr;
uint16_t getPowerButtonDuration() const {
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
}
+156
View File
@@ -0,0 +1,156 @@
#include "FontInstaller.h"
#include <HalStorage.h>
#include <Logging.h>
#include <cctype>
#include <cstring>
#include "CrossPointSettings.h"
FontInstaller::FontInstaller(SdCardFontRegistry& registry) : registry_(registry) {}
bool FontInstaller::isValidFamilyName(const char* name) {
if (name == nullptr || name[0] == '\0') return false;
// Reject path traversal
if (strstr(name, "..") != nullptr) return false;
if (strchr(name, '/') != nullptr) return false;
if (strchr(name, '\\') != nullptr) return false;
for (const char* p = name; *p != '\0'; ++p) {
char c = *p;
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') {
return false;
}
}
return true;
}
bool FontInstaller::isValidCpfontFilename(const char* name) {
if (name == nullptr || name[0] == '\0') return false;
// Reject path separators / traversal up front. Anything that could escape
// the family directory or refer to a different one is a hard reject.
if (strstr(name, "..") != nullptr) return false;
if (strchr(name, '/') != nullptr) return false;
if (strchr(name, '\\') != nullptr) return false;
// Must end with ".cpfont" exactly.
static constexpr char kExt[] = ".cpfont";
static constexpr size_t kExtLen = sizeof(kExt) - 1;
size_t nameLen = strlen(name);
if (nameLen <= kExtLen) return false;
if (strcmp(name + nameLen - kExtLen, kExt) != 0) return false;
// Basename (before .cpfont) must be alphanumeric + hyphen + underscore only.
// No additional dots — keeps stray "Foo.cpfont.tmp"-style names out.
size_t baseLen = nameLen - kExtLen;
for (size_t i = 0; i < baseLen; ++i) {
char c = name[i];
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') {
return false;
}
}
return true;
}
bool FontInstaller::ensureFamilyDir(const char* familyName) {
// Reuse the family's existing root if installed; otherwise pick the
// default-write root (hidden if no roots exist yet).
const char* root = SdCardFontRegistry::findFamilyRoot(familyName);
if (!root) root = SdCardFontRegistry::defaultWriteRoot();
if (!Storage.exists(root)) {
if (!Storage.mkdir(root)) {
LOG_ERR("FONT", "Failed to create fonts dir: %s", root);
return false;
}
}
char dirPath[160];
snprintf(dirPath, sizeof(dirPath), "%s/%s", root, familyName);
if (!Storage.exists(dirPath)) {
if (!Storage.mkdir(dirPath)) {
LOG_ERR("FONT", "Failed to create family dir: %s", dirPath);
return false;
}
}
return true;
}
bool FontInstaller::validateCpfontFile(const char* path) {
FsFile file;
if (!Storage.openFileForRead("FONT", path, file)) {
LOG_ERR("FONT", "Cannot open for validation: %s", path);
return false;
}
uint8_t magic[CPFONT_MAGIC_LEN];
size_t bytesRead = file.read(magic, CPFONT_MAGIC_LEN);
file.close();
if (bytesRead < CPFONT_MAGIC_LEN) {
LOG_ERR("FONT", "File too small: %s (%zu bytes)", path, bytesRead);
return false;
}
if (memcmp(magic, "CPFONT\0\0", CPFONT_MAGIC_LEN) != 0) {
LOG_ERR("FONT", "Bad magic in: %s", path);
return false;
}
return true;
}
void FontInstaller::buildFontPath(const char* family, const char* filename, char* outBuf, size_t outBufSize) {
// Use the same root selection as ensureFamilyDir: existing install dir wins,
// otherwise the default-write root.
const char* root = SdCardFontRegistry::findFamilyRoot(family);
if (!root) root = SdCardFontRegistry::defaultWriteRoot();
snprintf(outBuf, outBufSize, "%s/%s/%s", root, family, filename);
}
FontInstaller::Error FontInstaller::deleteFamily(const char* familyName) {
if (!isValidFamilyName(familyName)) {
return Error::INVALID_FAMILY_NAME;
}
// A family may exist in either root (or, edge case, both). Remove from both.
const char* roots[] = {SdCardFontRegistry::FONTS_DIR_HIDDEN, SdCardFontRegistry::FONTS_DIR_VISIBLE};
bool removedAny = false;
bool sawAny = false;
for (const char* root : roots) {
char dirPath[160];
snprintf(dirPath, sizeof(dirPath), "%s/%s", root, familyName);
if (!Storage.exists(dirPath)) continue;
sawAny = true;
if (!Storage.removeDir(dirPath)) {
LOG_ERR("FONT", "Failed to remove family dir: %s", dirPath);
return Error::SD_WRITE_ERROR;
}
removedAny = true;
}
if (!sawAny) {
LOG_DBG("FONT", "Family not found in any fonts root: %s", familyName);
return Error::OK; // Already gone
}
(void)removedAny;
// If this was the active font, clear the setting
if (strcmp(SETTINGS.sdFontFamilyName, familyName) == 0) {
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
LOG_DBG("FONT", "Cleared active SD font (deleted family: %s)", familyName);
}
return Error::OK;
}
void FontInstaller::refreshRegistry() { registry_.discover(); }
bool FontInstaller::isFamilyInstalled(const char* familyName) const {
return registry_.findFamily(familyName) != nullptr;
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <SdCardFontRegistry.h>
#include <cstddef>
#include <cstdint>
/// Shared utility for font installation (device download + browser upload).
/// Handles directory creation, file validation, deletion, and registry refresh.
class FontInstaller {
public:
enum class Error {
OK,
INVALID_FAMILY_NAME,
INVALID_FILE,
SD_WRITE_ERROR,
MAX_FAMILIES_REACHED,
};
explicit FontInstaller(SdCardFontRegistry& registry);
/// Validate a family name: alphanumeric + hyphen + underscore only, no path traversal.
static bool isValidFamilyName(const char* name);
/// Validate a .cpfont filename: ends with ".cpfont", no path separators or
/// traversal sequences, basename uses only alphanumeric + hyphen + underscore
/// + dot (only as the extension separator). Rejects "../foo.cpfont" and
/// "evil/foo.cpfont".
static bool isValidCpfontFilename(const char* name);
/// Ensure /<root>/<family>/ exists, where <root> is /.fonts (preferred) or /fonts.
/// Re-uses the existing root if the family is already installed; otherwise
/// creates it under SdCardFontRegistry::defaultWriteRoot().
bool ensureFamilyDir(const char* familyName);
/// Validate a .cpfont file on disk (check magic bytes).
bool validateCpfontFile(const char* path);
/// Build the full SD path for a font file.
/// Writes "/<root>/<family>/<filename>" to outBuf, choosing <root> the same
/// way ensureFamilyDir does (existing install dir, else default-write root).
static void buildFontPath(const char* family, const char* filename, char* outBuf, size_t outBufSize);
/// Delete a family directory and all .cpfont files in it.
/// If the deleted family is the active reader font, clears the setting.
Error deleteFamily(const char* familyName);
/// Re-run registry discovery to pick up new/removed fonts.
void refreshRegistry();
/// Check whether a family name already exists in the registry.
bool isFamilyInstalled(const char* familyName) const;
private:
SdCardFontRegistry& registry_;
static constexpr const char* CPFONT_MAGIC = "CPFONT\0";
static constexpr size_t CPFONT_MAGIC_LEN = 8;
};
+17
View File
@@ -140,6 +140,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
doc["frontButtonConfirm"] = s.frontButtonConfirm;
doc["frontButtonLeft"] = s.frontButtonLeft;
doc["frontButtonRight"] = s.frontButtonRight;
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
doc["fontFamily"] = s.fontFamily;
// SD card font family name — not in SettingsList, save manually
if (s.sdFontFamilyName[0] != '\0') {
doc["sdFontFamilyName"] = s.sdFontFamilyName;
}
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
@@ -224,6 +234,13 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
CrossPointSettings::validateFrontButtonMapping(s);
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
s.fontFamily = clamp(doc["fontFamily"] | (uint8_t)0, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
// SD card font family name — not in SettingsList, load manually
const char* sfn = doc["sdFontFamilyName"] | "";
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
// Language -- stored as code string for stability across enum reorders.
if (doc["language"].is<const char*>()) {
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "SdCardFontSystem.h"
class GfxRenderer;
// Global SD card font system instance (defined in main.cpp).
extern SdCardFontSystem sdFontSystem;
// Ensure the correct SD card font family is loaded for current settings.
// Defined in main.cpp; call before entering the reader or after settings change.
extern void ensureSdFontLoaded();
+109
View File
@@ -0,0 +1,109 @@
#include "SdCardFontSystem.h"
#include <GfxRenderer.h>
#include <Logging.h>
#include "CrossPointSettings.h"
static uint8_t fontSizeEnumFromSettings() {
uint8_t e = SETTINGS.fontSize;
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
return e;
}
void SdCardFontSystem::begin(GfxRenderer& renderer) {
registry_.discover();
// Register this system as the SD font ID resolver in settings.
// Uses a static trampoline since CrossPointSettings stores a plain function pointer.
SETTINGS.sdFontIdResolver = [](void* ctx, const char* familyName, uint8_t fontSizeEnum) -> int {
return static_cast<SdCardFontSystem*>(ctx)->resolveFontId(familyName, fontSizeEnum);
};
SETTINGS.sdFontResolverCtx = this;
// If user has a saved SD font selection, load it
if (SETTINGS.sdFontFamilyName[0] != '\0') {
const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName);
if (family) {
if (manager_.loadFamily(*family, renderer, fontSizeEnumFromSettings())) {
LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName);
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
SETTINGS.sdFontFamilyName[0] = '\0';
}
} else {
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
SETTINGS.sdFontFamilyName[0] = '\0';
}
}
LOG_DBG("SDFS", "SD font system ready (%d families discovered)", registry_.getFamilyCount());
}
void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
// If the web server (or another task) installed/deleted fonts, re-discover.
// Track whether we just re-discovered so we can force a reload below even
// when the wanted family/size still maps to the same point size — the file
// contents on disk may have changed (e.g. user re-uploaded a new build).
const bool registryWasDirty = registryDirty_.exchange(false, std::memory_order_acquire);
if (registryWasDirty) {
LOG_DBG("SDFS", "Registry dirty — re-discovering fonts");
registry_.discover();
}
const char* wantedFamily = SETTINGS.sdFontFamilyName;
const std::string& currentFamily = manager_.currentFamilyName();
const uint8_t sizeEnum = fontSizeEnumFromSettings();
if (wantedFamily[0] == '\0') {
if (!currentFamily.empty()) {
manager_.unloadAll(renderer);
}
return;
}
// Reload if family changed OR if the user-selected size maps to a
// different file than what's currently loaded OR if the registry was
// just rediscovered (file may have been replaced on disk).
bool familyMatches = (currentFamily == wantedFamily);
if (familyMatches) {
const auto* family = registry_.findFamily(wantedFamily);
if (!family) {
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
manager_.unloadAll(renderer);
SETTINGS.sdFontFamilyName[0] = '\0';
return;
}
auto sizes = family->availableSizes();
uint8_t idx = sizeEnum;
if (idx >= sizes.size()) idx = sizes.size() - 1;
uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx];
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return;
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
}
if (!currentFamily.empty()) {
manager_.unloadAll(renderer);
}
const auto* family = registry_.findFamily(wantedFamily);
if (family) {
if (manager_.loadFamily(*family, renderer, sizeEnum)) {
LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily);
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
SETTINGS.sdFontFamilyName[0] = '\0';
}
} else {
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
SETTINGS.sdFontFamilyName[0] = '\0';
}
}
int SdCardFontSystem::resolveFontId(const char* familyName, uint8_t /*fontSizeEnum*/) const {
// The manager loads exactly one size (closest to SETTINGS.fontSize), so the
// enum is implicit — always return the single loaded font ID for this family.
// ensureLoaded() must have been called with the current settings before this.
return manager_.getFontId(familyName);
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <SdCardFontManager.h>
#include <SdCardFontRegistry.h>
#include <atomic>
class GfxRenderer;
/// Facade that owns the SD card font registry, manager, and resolver logic.
/// Hides implementation details behind a single begin() + ensureLoaded() API.
class SdCardFontSystem {
public:
SdCardFontSystem() = default;
SdCardFontSystem(const SdCardFontSystem&) = delete;
SdCardFontSystem& operator=(const SdCardFontSystem&) = delete;
/// Discover SD card fonts and load user's saved selection. Call once during setup.
void begin(GfxRenderer& renderer);
/// Ensure the correct SD font family is loaded for the current settings.
/// Call before entering the reader or after settings change.
/// Also re-discovers if the registry has been marked dirty (e.g. by web upload).
void ensureLoaded(GfxRenderer& renderer);
/// Resolve an SD card font ID from family name + fontSize enum.
/// Returns 0 if not found. Used by CrossPointSettings::getReaderFontId().
int resolveFontId(const char* familyName, uint8_t fontSizeEnum) const;
/// Access the registry (e.g. for settings UI to enumerate available fonts).
const SdCardFontRegistry& registry() const { return registry_; }
/// Non-const access to the registry (for FontInstaller).
SdCardFontRegistry& registry() { return registry_; }
/// Mark the registry as needing re-discovery.
/// Thread-safe: can be called from the web server task.
void markRegistryDirty() { registryDirty_.store(true, std::memory_order_release); }
/// If the registry is dirty, re-scan the SD card now and clear the flag.
/// Used by the web UI so uploaded/deleted fonts appear in the list
/// without waiting for the reader activity to run ensureLoaded().
void refreshIfDirty() {
if (registryDirty_.exchange(false, std::memory_order_acquire)) {
registry_.discover();
}
}
private:
SdCardFontRegistry registry_;
SdCardFontManager manager_;
std::atomic<bool> registryDirty_{false};
};
+101 -3
View File
@@ -2,18 +2,105 @@
#include <HalTiltSensor.h>
#include <I18n.h>
#include <SdCardFontRegistry.h>
#include <algorithm>
#include <cstring>
#include <iterator>
#include <vector>
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h"
// Build the font family setting dynamically. When registry is non-null, SD card fonts
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
// Built-in font labels (StrId)
std::vector<StrId> enumValues = {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC};
// Runtime string labels for SD card fonts
std::vector<std::string> enumStringValues;
// Reserve: first CrossPointSettings::BUILTIN_FONT_COUNT entries use StrId, rest use strings
if (registry) {
const auto& families = registry->getFamilies();
enumStringValues.reserve(families.size());
std::transform(families.begin(), families.end(), std::back_inserter(enumStringValues),
[](const SdCardFontFamilyInfo& f) { return f.name; });
}
// Capture the SD font count for the lambdas
const int sdFontCount = static_cast<int>(enumStringValues.size());
// Total option count = built-in + SD card families
// For the combined enumStringValues: we need all entries as strings (built-in names + SD names)
// The render code checks enumStringValues first, then enumValues. So we build enumStringValues
// with all options when SD fonts are present.
std::vector<std::string> allStringValues;
if (sdFontCount > 0) {
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SERIF));
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SANS));
allStringValues.push_back(I18N.get(StrId::STR_OPEN_DYSLEXIC));
allStringValues.insert(allStringValues.end(), enumStringValues.begin(), enumStringValues.end());
}
SettingInfo s;
s.nameId = StrId::STR_FONT_FAMILY;
s.type = SettingType::ENUM;
s.enumValues = std::move(enumValues);
s.enumStringValues = std::move(allStringValues);
s.key = "fontFamily";
s.category = StrId::STR_CAT_READER;
// Capture registry families by copy for the lambdas
std::vector<std::string> sdFamilyNames;
if (registry) {
const auto& families = registry->getFamilies();
sdFamilyNames.reserve(families.size());
std::transform(families.begin(), families.end(), std::back_inserter(sdFamilyNames),
[](const SdCardFontFamilyInfo& f) { return f.name; });
}
s.valueGetter = [sdFamilyNames]() -> uint8_t {
// If an SD card font is selected, find its index
if (SETTINGS.sdFontFamilyName[0] != '\0') {
for (int i = 0; i < static_cast<int>(sdFamilyNames.size()); i++) {
if (sdFamilyNames[i] == SETTINGS.sdFontFamilyName) {
return static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i);
}
}
// SD font name not found in registry — fall through to built-in
}
return SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
};
s.valueSetter = [sdFamilyNames](uint8_t v) {
if (v < CrossPointSettings::BUILTIN_FONT_COUNT) {
SETTINGS.fontFamily = v;
SETTINGS.sdFontFamilyName[0] = '\0';
} else {
int sdIdx = v - CrossPointSettings::BUILTIN_FONT_COUNT;
if (sdIdx < static_cast<int>(sdFamilyNames.size())) {
strncpy(SETTINGS.sdFontFamilyName, sdFamilyNames[sdIdx].c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
}
}
};
return s;
}
// Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only.
inline const std::vector<SettingInfo>& getSettingsList() {
static const std::vector<SettingInfo> list = [] {
//
// The static list is constructed exactly once (master's optimization, #1086 +
// #1636) so the per-entry SettingInfo cost is paid once. When an
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
// font-family entry is replaced in a per-call copy with a registry-aware
// version. Callers without SD fonts pay only a vector copy.
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) {
static const std::vector<SettingInfo> baseList = [] {
std::vector<SettingInfo> v = {
// --- Display ---
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
@@ -40,6 +127,8 @@ inline const std::vector<SettingInfo>& getSettingsList() {
StrId::STR_CAT_DISPLAY),
// --- Reader ---
// Built-in font-family entry. Replaced per-call with a registry-aware
// version when SD fonts are installed.
SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
StrId::STR_CAT_READER),
@@ -78,6 +167,7 @@ inline const std::vector<SettingInfo>& getSettingsList() {
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
// --- System ---
SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout,
{StrId::STR_MIN_1, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15, StrId::STR_MIN_30},
@@ -149,5 +239,13 @@ inline const std::vector<SettingInfo>& getSettingsList() {
}
return v;
}();
return list;
std::vector<SettingInfo> v = baseList;
if (registry && registry->getFamilyCount() > 0) {
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
if (it != v.end()) {
*it = buildFontFamilySetting(registry);
}
}
return v;
}
+2
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include "OpdsServerStore.h"
#include "SdCardFontGlobals.h"
#include "boot_sleep/BootActivity.h"
#include "boot_sleep/SleepActivity.h"
#include "browser/OpdsBookBrowserActivity.h"
@@ -193,6 +194,7 @@ void ActivityManager::goToBrowser() {
}
void ActivityManager::goToReader(std::string path) {
ensureSdFontLoaded();
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
}
+3 -11
View File
@@ -582,6 +582,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
SETTINGS.imageRendering)) {
LOG_DBG("ERS", "Cache not found, building...");
GUI.drawPopup(renderer, tr(STR_INDEXING));
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
@@ -745,27 +747,19 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
const auto t0 = millis();
auto* fcm = renderer.getFontCacheManager();
fcm->resetStats();
// Font prewarm: scan pass accumulates text, then prewarm, then real render
const uint32_t heapBefore = esp_get_free_heap_size();
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
scope.endScanAndPrewarm();
const uint32_t heapAfter = esp_get_free_heap_size();
fcm->logStats("prewarm");
const auto tPrewarm = millis();
LOG_DBG("ERS", "Heap: before=%lu after=%lu delta=%ld", heapBefore, heapAfter,
(int32_t)heapAfter - (int32_t)heapBefore);
// Force special handling for pages with images when anti-aliasing is on
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar();
fcm->logStats("bw_render");
const auto tBwRender = millis();
if (imagePageWithAA) {
@@ -816,8 +810,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderer.displayGrayBuffer();
const auto tGrayDisplay = millis();
renderer.setRenderMode(GfxRenderer::BW);
fcm->logStats("gray");
// restore the bw data
renderer.restoreBwBuffer();
const auto tBwRestore = millis();
@@ -0,0 +1,426 @@
#include "FontDownloadActivity.h"
#include <ArduinoJson.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include "MappedInputManager.h"
#include "SdCardFontGlobals.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
FontDownloadActivity::FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("FontDownload", renderer, mappedInput), fontInstaller_(sdFontSystem.registry()) {}
// --- Lifecycle ---
void FontDownloadActivity::onEnter() {
Activity::onEnter();
WiFi.mode(WIFI_STA);
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
}
void FontDownloadActivity::onExit() {
Activity::onExit();
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_OFF);
delay(100);
}
void FontDownloadActivity::onWifiSelectionComplete(const bool success) {
if (!success) {
finish();
return;
}
{
RenderLock lock(*this);
state_ = LOADING_MANIFEST;
}
requestUpdateAndWait();
if (!fetchAndParseManifest()) {
{
RenderLock lock(*this);
state_ = ERROR;
}
return;
}
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
selectedIndex_ = 0;
}
}
// --- Manifest fetching ---
bool FontDownloadActivity::fetchAndParseManifest() {
// Download manifest to a temp file on SD card to avoid holding both
// TLS buffers and the full JSON string in RAM simultaneously.
static constexpr const char* MANIFEST_TMP = "/fonts_manifest.tmp";
auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr);
if (result != HttpDownloader::OK) {
LOG_ERR("FONT", "Failed to fetch manifest from %s", FONT_MANIFEST_URL);
errorMessage_ = "Failed to fetch font list";
Storage.remove(MANIFEST_TMP);
return false;
}
// HTTP client is now closed — TLS buffers freed. Parse JSON from file.
FsFile manifestFile;
if (!Storage.openFileForRead("FONT", MANIFEST_TMP, manifestFile)) {
LOG_ERR("FONT", "Failed to open temp manifest");
Storage.remove(MANIFEST_TMP);
errorMessage_ = "Failed to read font list";
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, manifestFile);
manifestFile.close();
Storage.remove(MANIFEST_TMP);
if (err) {
LOG_ERR("FONT", "Manifest parse error: %s", err.c_str());
errorMessage_ = "Invalid font manifest";
return false;
}
int version = doc["version"] | 0;
if (version != FONTS_MANIFEST_VERSION) {
LOG_ERR("FONT", "Unsupported manifest version: %d", version);
errorMessage_ = "Unsupported manifest version";
return false;
}
baseUrl_ = doc["baseUrl"] | "";
families_.clear();
JsonArray familiesArr = doc["families"].as<JsonArray>();
families_.reserve(familiesArr.size());
for (JsonObject fObj : familiesArr) {
ManifestFamily family;
family.name = fObj["name"] | "";
family.description = fObj["description"] | "";
for (JsonVariant s : fObj["styles"].as<JsonArray>()) {
family.styles.push_back(s.as<std::string>());
}
family.totalSize = 0;
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
ManifestFile file;
file.name = fileObj["name"] | "";
file.size = fileObj["size"] | 0;
family.totalSize += file.size;
family.files.push_back(std::move(file));
}
family.installed = fontInstaller_.isFamilyInstalled(family.name.c_str());
// Detect updates by comparing manifest file sizes with files on disk.
// Not a checksum, but a size mismatch reliably indicates a rebuild in practice.
if (family.installed) {
for (const auto& file : family.files) {
char path[128];
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path));
FsFile f;
if (Storage.openFileForRead("FONT", path, f)) {
size_t actual = f.fileSize();
f.close();
if (actual != file.size) {
family.hasUpdate = true;
break;
}
} else {
// File missing on disk but family dir exists — treat as update
family.hasUpdate = true;
break;
}
}
}
families_.push_back(std::move(family));
}
LOG_DBG("FONT", "Manifest loaded: %zu families", families_.size());
return true;
}
// --- Download ---
void FontDownloadActivity::downloadAll() {
for (size_t i = 0; i < families_.size(); i++) {
if (families_[i].installed && !families_[i].hasUpdate) continue;
downloadFamily(families_[i]);
if (state_ == ERROR) return;
}
{
RenderLock lock(*this);
state_ = COMPLETE;
}
}
size_t FontDownloadActivity::totalUninstalledSize() const {
size_t total = 0;
for (const auto& f : families_) {
if (!f.installed || f.hasUpdate) total += f.totalSize;
}
return total;
}
void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
{
RenderLock lock(*this);
state_ = DOWNLOADING;
downloadingFamilyIndex_ = static_cast<int>(&family - families_.data());
currentFileIndex_ = 0;
currentFileTotal_ = family.files.size();
fileProgress_ = 0;
fileTotal_ = 0;
}
requestUpdateAndWait();
if (!fontInstaller_.ensureFamilyDir(family.name.c_str())) {
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = "Failed to create font directory";
return;
}
for (size_t i = 0; i < family.files.size(); i++) {
const auto& file = family.files[i];
{
RenderLock lock(*this);
currentFileIndex_ = i;
fileProgress_ = 0;
fileTotal_ = file.size;
}
requestUpdateAndWait();
char destPath[128];
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), destPath, sizeof(destPath));
std::string url = baseUrl_ + file.name;
auto result = HttpDownloader::downloadToFile(url, destPath, [this](size_t downloaded, size_t total) {
fileProgress_ = downloaded;
fileTotal_ = total;
requestUpdate(true);
});
if (result != HttpDownloader::OK) {
LOG_ERR("FONT", "Download failed: %s (%d)", file.name.c_str(), result);
fontInstaller_.deleteFamily(family.name.c_str());
family.installed = false;
family.hasUpdate = false;
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = "Download failed: " + file.name;
return;
}
if (!fontInstaller_.validateCpfontFile(destPath)) {
LOG_ERR("FONT", "Invalid .cpfont: %s", destPath);
fontInstaller_.deleteFamily(family.name.c_str());
family.installed = false;
family.hasUpdate = false;
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = "Invalid font file: " + file.name;
return;
}
}
fontInstaller_.refreshRegistry();
family.installed = true;
{
RenderLock lock(*this);
state_ = COMPLETE;
}
}
// --- Input handling ---
void FontDownloadActivity::loop() {
if (state_ == FAMILY_LIST) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
buttonNavigator_.onNextRelease([this] {
if (selectedIndex_ < listItemCount() - 1) {
selectedIndex_++;
requestUpdate();
}
});
buttonNavigator_.onPreviousRelease([this] {
if (selectedIndex_ > 0) {
selectedIndex_--;
requestUpdate();
}
});
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (!families_.empty()) {
if (isDownloadAllSelected()) {
downloadAll();
} else {
const auto& family = families_[familyIndexFromList(selectedIndex_)];
if (!family.installed || family.hasUpdate) {
downloadFamily(families_[familyIndexFromList(selectedIndex_)]);
}
}
requestUpdateAndWait();
return;
}
}
} else if (state_ == COMPLETE) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
}
requestUpdate();
}
} else if (state_ == ERROR) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
}
requestUpdate();
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast<int>(families_.size())) {
downloadFamily(families_[downloadingFamilyIndex_]);
requestUpdateAndWait();
return;
} else {
{
RenderLock lock(*this);
state_ = FAMILY_LIST;
}
requestUpdate();
}
}
}
}
// --- Rendering ---
std::string FontDownloadActivity::formatSize(size_t bytes) {
char buf[32];
if (bytes >= 1024 * 1024) {
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
} else if (bytes >= 1024) {
snprintf(buf, sizeof(buf), "%.0f KB", static_cast<double>(bytes) / 1024.0);
} else {
snprintf(buf, sizeof(buf), "%zu B", bytes);
}
return buf;
}
void FontDownloadActivity::render(RenderLock&&) {
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_DOWNLOAD));
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const auto centerY = (pageHeight - lineHeight) / 2;
if (state_ == LOADING_MANIFEST) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_LOADING_FONT_LIST));
} else if (state_ == FAMILY_LIST) {
if (families_.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_NO_FONTS_AVAILABLE));
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
GUI.drawList(
renderer,
Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing},
listItemCount(), selectedIndex_,
[this](int index) -> std::string {
if (index == 0) {
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalUninstalledSize()) + ")";
}
return families_[familyIndexFromList(index)].name;
},
nullptr, nullptr,
[this](int index) -> std::string {
if (index == 0) return "";
const auto& f = families_[familyIndexFromList(index)];
if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
if (f.installed) return tr(STR_INSTALLED);
return f.description;
},
true,
[this](int index) -> bool {
if (index == 0) return false;
const auto& f = families_[familyIndexFromList(index)];
// Dim installed fonts, but not those with updates available
return f.installed && !f.hasUpdate;
});
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
} else if (state_ == DOWNLOADING) {
const auto& family = families_[downloadingFamilyIndex_];
std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + family.name + " (" +
std::to_string(currentFileIndex_ + 1) + "/" + std::to_string(currentFileTotal_) + ")";
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, statusText.c_str());
float progress = 0;
if (fileTotal_ > 0) {
progress = static_cast<float>(fileProgress_) / static_cast<float>(fileTotal_);
}
int barY = centerY + metrics.verticalSpacing;
GUI.drawProgressBar(
renderer,
Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
static_cast<int>(progress * 100), 100);
int percentY = barY + metrics.progressBarHeight + metrics.verticalSpacing;
renderer.drawCenteredText(UI_10_FONT_ID, percentY,
(std::to_string(static_cast<int>(progress * 100)) + "%").c_str());
} else if (state_ == COMPLETE) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else if (state_ == ERROR) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_FONT_INSTALL_FAILED), true,
EpdFontFamily::BOLD);
if (!errorMessage_.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str());
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}
@@ -0,0 +1,91 @@
#pragma once
#include <string>
#include <vector>
#include "FontInstaller.h"
#include "SdCardFont.h"
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
// JSON schema version of the fonts.json manifest. The canonical version for
// the build tooling lives in lib/EpdFont/scripts/cpfont_version.py. This
// firmware-side copy must be bumped manually when the firmware is updated to
// support a new manifest schema.
#define FONTS_MANIFEST_VERSION 1
#ifndef FONT_MANIFEST_URL
// Manifest + .cpfont assets are published by .github/workflows/release-fonts.yml
// to the crosspoint-fonts repo under the "sd-fonts-m<META>-b<BIN>" tag. The tag
// pattern must stay in sync with the workflow; it derives its version numbers
// from lib/EpdFont/scripts/cpfont_version.py.
#define FONT_MANIFEST_URL_STRINGIFY_INNER(x) #x
#define FONT_MANIFEST_URL_STRINGIFY(x) FONT_MANIFEST_URL_STRINGIFY_INNER(x)
#define FONT_MANIFEST_URL \
"https://github.com/crosspoint-reader/crosspoint-fonts/releases/download/sd-fonts-m" FONT_MANIFEST_URL_STRINGIFY( \
FONTS_MANIFEST_VERSION) "-b" FONT_MANIFEST_URL_STRINGIFY(CPFONT_VERSION) "/fonts.json"
#endif
class FontDownloadActivity : public Activity {
public:
explicit FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput);
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool preventAutoSleep() override { return state_ == LOADING_MANIFEST || state_ == DOWNLOADING; }
bool skipLoopDelay() override { return true; }
private:
enum State {
WIFI_SELECTION,
LOADING_MANIFEST,
FAMILY_LIST,
DOWNLOADING,
COMPLETE,
ERROR,
};
struct ManifestFile {
std::string name;
size_t size = 0;
};
struct ManifestFamily {
std::string name;
std::string description;
std::vector<std::string> styles;
std::vector<ManifestFile> files;
size_t totalSize = 0;
bool installed = false;
bool hasUpdate = false;
};
State state_ = WIFI_SELECTION;
FontInstaller fontInstaller_;
ButtonNavigator buttonNavigator_;
// Manifest data
std::string baseUrl_;
std::vector<ManifestFamily> families_;
int selectedIndex_ = 0;
// Download progress
size_t currentFileIndex_ = 0;
size_t currentFileTotal_ = 0;
size_t fileProgress_ = 0;
size_t fileTotal_ = 0;
int downloadingFamilyIndex_ = 0;
std::string errorMessage_;
void onWifiSelectionComplete(bool success);
bool fetchAndParseManifest();
void downloadFamily(ManifestFamily& family);
void downloadAll();
bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); }
int familyIndexFromList(int listIndex) const { return listIndex - 1; }
int listItemCount() const { return families_.empty() ? 0 : static_cast<int>(families_.size()) + 1; }
size_t totalUninstalledSize() const;
static std::string formatSize(size_t bytes);
};
@@ -0,0 +1,126 @@
#include "FontSelectionActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const SdCardFontRegistry* registry)
: Activity("FontSelect", renderer, mappedInput), registry_(registry) {}
void FontSelectionActivity::onEnter() {
Activity::onEnter();
// Build combined font list: built-in + SD card fonts
fonts_.clear();
fonts_.reserve(CrossPointSettings::BUILTIN_FONT_COUNT + (registry_ ? registry_->getFamilyCount() : 0));
fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, 0});
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, 1});
fonts_.push_back({I18N.get(StrId::STR_OPEN_DYSLEXIC), true, 2});
if (registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
fonts_.push_back({families[i].name, false, static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i)});
}
}
// Find current selection
selectedIndex_ = 0;
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == SETTINGS.sdFontFamilyName) {
selectedIndex_ = CrossPointSettings::BUILTIN_FONT_COUNT + i;
break;
}
}
} else {
selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
}
requestUpdate();
}
void FontSelectionActivity::onExit() { Activity::onExit(); }
void FontSelectionActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
return;
}
buttonNavigator_.onNextRelease([this] {
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, static_cast<int>(fonts_.size()));
requestUpdate();
});
buttonNavigator_.onPreviousRelease([this] {
selectedIndex_ = ButtonNavigator::previousIndex(selectedIndex_, static_cast<int>(fonts_.size()));
requestUpdate();
});
}
void FontSelectionActivity::handleSelection() {
const auto& font = fonts_[selectedIndex_];
if (font.settingIndex < CrossPointSettings::BUILTIN_FONT_COUNT) {
SETTINGS.fontFamily = font.settingIndex;
SETTINGS.sdFontFamilyName[0] = '\0';
} else if (registry_) {
int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
const auto& families = registry_->getFamilies();
if (sdIdx < static_cast<int>(families.size())) {
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
}
}
finish();
}
void FontSelectionActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto& metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_FAMILY));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
// Determine which font index is currently active (to mark as "Selected")
int currentFontIndex = 0;
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == SETTINGS.sdFontFamilyName) {
currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + i;
break;
}
}
} else {
currentFontIndex = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
}
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
[this](int index) { return fonts_[index].name; }, nullptr, nullptr,
[this, currentFontIndex](int index) -> std::string { return index == currentFontIndex ? tr(STR_SELECTED) : ""; },
true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,34 @@
#pragma once
#include <SdCardFontRegistry.h>
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
class FontSelectionActivity final : public Activity {
public:
explicit FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const SdCardFontRegistry* registry);
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
void handleSelection();
struct FontEntry {
std::string name;
bool isBuiltin;
uint8_t settingIndex; // index used by valueSetter
};
const SdCardFontRegistry* registry_;
ButtonNavigator buttonNavigator_;
std::vector<FontEntry> fonts_;
int selectedIndex_ = 0;
};
+64 -9
View File
@@ -6,11 +6,14 @@
#include "ButtonRemapActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
#include "FontDownloadActivity.h"
#include "FontSelectionActivity.h"
#include "KOReaderSettingsActivity.h"
#include "LanguageSelectActivity.h"
#include "MappedInputManager.h"
#include "OpdsServerListActivity.h"
#include "OtaUpdateActivity.h"
#include "SdCardFontGlobals.h"
#include "SdFirmwareUpdateActivity.h"
#include "SettingsList.h"
#include "StatusBarSettingsActivity.h"
@@ -21,16 +24,17 @@
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
void SettingsActivity::onEnter() {
Activity::onEnter();
// Build per-category vectors from the shared settings list
void SettingsActivity::rebuildSettingsLists() {
displaySettings.clear();
readerSettings.clear();
controlsSettings.clear();
systemSettings.clear();
for (const auto& setting : getSettingsList()) {
// Pick up any fonts uploaded/deleted over the web server since the last
// reader activity ran — otherwise the font-family picker shows stale list.
sdFontSystem.refreshIfDirty();
for (auto& setting : getSettingsList(&sdFontSystem.registry())) {
if (setting.category == StrId::STR_NONE_OPT) continue;
if (setting.category == StrId::STR_CAT_DISPLAY) {
displaySettings.push_back(setting);
@@ -41,7 +45,6 @@ void SettingsActivity::onEnter() {
} else if (setting.category == StrId::STR_CAT_SYSTEM) {
systemSettings.push_back(setting);
}
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
}
// Append device-only ACTION items
@@ -51,18 +54,41 @@ void SettingsActivity::onEnter() {
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
systemSettings.push_back(SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
// Insert "Download Fonts" right after the font family setting so users discover it naturally
readerSettings.insert(readerSettings.begin() + 1,
SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
// Update currentSettings pointer and count for the active category
switch (selectedCategoryIndex) {
case 0:
currentSettings = &displaySettings;
break;
case 1:
currentSettings = &readerSettings;
break;
case 2:
currentSettings = &controlsSettings;
break;
case 3:
currentSettings = &systemSettings;
break;
}
settingsCount = static_cast<int>(currentSettings->size());
}
void SettingsActivity::onEnter() {
Activity::onEnter();
// Reset selection to first category
selectedCategoryIndex = 0;
selectedSettingIndex = 0;
// Initialize with first category (Display)
currentSettings = &displaySettings;
settingsCount = static_cast<int>(displaySettings.size());
rebuildSettingsLists();
// Trigger first update
requestUpdate();
@@ -159,6 +185,21 @@ void SettingsActivity::toggleCurrentSetting() {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
if (setting.nameId == StrId::STR_FONT_FAMILY) {
// Launch font selection submenu instead of cycling
startActivityForResult(std::make_unique<FontSelectionActivity>(renderer, mappedInput, &sdFontSystem.registry()),
[this](const ActivityResult&) {
SETTINGS.saveToFile();
rebuildSettingsLists();
});
return;
}
const uint8_t totalValues = setting.enumStringValues.empty()
? static_cast<uint8_t>(setting.enumValues.size())
: static_cast<uint8_t>(setting.enumStringValues.size());
const uint8_t cur = setting.valueGetter();
setting.valueSetter((cur + 1) % totalValues);
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
@@ -194,6 +235,13 @@ void SettingsActivity::toggleCurrentSetting() {
case SettingAction::SdFirmwareUpdate:
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::DownloadFonts:
startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
SETTINGS.saveToFile();
rebuildSettingsLists();
});
break;
case SettingAction::Language:
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
break;
@@ -245,6 +293,13 @@ void SettingsActivity::render(RenderLock&&) {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(setting.valuePtr);
valueText = I18N.get(setting.enumValues[value]);
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
const uint8_t value = setting.valueGetter();
if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
valueText = setting.enumStringValues[value];
} else if (value < setting.enumValues.size()) {
valueText = I18N.get(setting.enumValues[value]);
}
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
}
@@ -22,6 +22,7 @@ enum class SettingAction {
CheckForUpdates,
SdFirmwareUpdate,
Language,
DownloadFonts,
};
struct SettingInfo {
@@ -29,6 +30,7 @@ struct SettingInfo {
SettingType type;
uint8_t CrossPointSettings::* valuePtr = nullptr;
std::vector<StrId> enumValues;
std::vector<std::string> enumStringValues; // runtime alternative to StrId enumValues (for SD card fonts etc.)
SettingAction action = SettingAction::None;
struct ValueRange {
@@ -159,6 +161,7 @@ class SettingsActivity final : public Activity {
void enterCategory(int categoryIndex);
void toggleCurrentSetting();
void rebuildSettingsLists();
public:
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
+12 -1
View File
@@ -231,7 +231,8 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
@@ -279,6 +280,16 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
// Apply checkerboard dither to create gray text effect for dimmed items
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
const int titleWidth = renderer.getTextWidth(font, item.c_str());
const int lineH = renderer.getLineHeight(font);
const int tx = rect.x + BaseMetrics::values.contentSidePadding;
for (int py = itemY; py < itemY + lineH; py++)
for (int px = tx; px < tx + titleWidth; px++)
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
}
if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
+2 -2
View File
@@ -138,8 +138,8 @@ class BaseTheme {
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr,
const std::function<UIIcon(int index)>& rowIcon = nullptr,
const std::function<std::string(int index)>& rowValue = nullptr,
bool highlightValue = false) const;
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
const std::function<bool(int index)>& rowDimmed = nullptr) const;
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title,
const char* subtitle = nullptr) const;
virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
+11 -1
View File
@@ -208,7 +208,8 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
@@ -267,6 +268,15 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
// Apply checkerboard dither to create gray text effect for dimmed items
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
const int titleWidth = renderer.getTextWidth(UI_10_FONT_ID, item.c_str());
const int lineH = renderer.getLineHeight(UI_10_FONT_ID);
for (int py = itemY + 7; py < itemY + 7 + lineH; py++)
for (int px = textX; px < textX + titleWidth; px++)
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
}
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
+1 -1
View File
@@ -59,7 +59,7 @@ class LyraTheme : public BaseTheme {
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon, const std::function<std::string(int index)>& rowValue,
bool highlightValue) const override;
bool highlightValue, const std::function<bool(int index)>& rowDimmed = nullptr) const override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const override;
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;
@@ -247,9 +247,11 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
(void)rowIcon;
(void)highlightValue;
(void)rowDimmed;
const bool hasSubtitle = static_cast<bool>(rowSubtitle);
const int titleLineHeight = renderer.getLineHeight(kTitleFontId);
const int subtitleLineHeight = renderer.getLineHeight(kSubtitleFontId);
@@ -56,8 +56,8 @@ class RoundedRaffTheme : public BaseTheme {
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr,
const std::function<UIIcon(int index)>& rowIcon = nullptr,
const std::function<std::string(int index)>& rowValue = nullptr,
bool highlightValue = false) const override;
const std::function<std::string(int index)>& rowValue = nullptr, bool highlightValue = false,
const std::function<bool(int index)>& rowDimmed = nullptr) const override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const override;
bool homeMenuShowsContinueReading() const { return true; }
+18
View File
@@ -16,3 +16,21 @@
#define UI_10_FONT_ID (22918846)
#define UI_12_FONT_ID (1635686837)
#define SMALL_FONT_ID (674098198)
// Font ID 0 is reserved as the "not found" sentinel.
// Guard against any hash accidentally producing 0.
static_assert(NOTOSERIF_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSERIF_14_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSERIF_16_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSERIF_18_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_14_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_16_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(NOTOSANS_18_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_8_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_10_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(OPENDYSLEXIC_14_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(UI_10_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(UI_12_FONT_ID != 0, "Font ID collision with sentinel");
static_assert(SMALL_FONT_ID != 0, "Font ID collision with sentinel");
+9 -1
View File
@@ -22,6 +22,7 @@
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "RecentBooksStore.h"
#include "SdCardFontSystem.h"
#include "activities/Activity.h"
#include "activities/ActivityManager.h"
#include "activities/settings/SdFirmwareUpdateActivity.h"
@@ -34,7 +35,8 @@ MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display);
ActivityManager activityManager(renderer, mappedInputManager);
FontDecompressor fontDecompressor;
FontCacheManager fontCacheManager(renderer.getFontMap());
SdCardFontSystem sdFontSystem;
FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts());
// Fonts
EpdFont notoserif14RegularFont(&notoserif_14_regular);
@@ -195,6 +197,8 @@ void enterDeepSleep() {
powerManager.startDeepSleep(gpio);
}
void ensureSdFontLoaded() { sdFontSystem.ensureLoaded(renderer); }
void setupDisplayAndFonts() {
display.begin();
renderer.begin();
@@ -225,6 +229,10 @@ void setupDisplayAndFonts() {
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
// Discover and load SD card fonts
sdFontSystem.begin(renderer);
LOG_DBG("MAIN", "Fonts setup");
}
+222 -5
View File
@@ -11,11 +11,15 @@
#include <algorithm>
#include "CrossPointSettings.h"
#include "FontInstaller.h"
#include "OpdsServerStore.h"
#include "SdCardFontGlobals.h"
#include "SdCardFontSystem.h"
#include "SettingsList.h"
#include "WebDAVHandler.h"
#include "WifiCredentialStore.h"
#include "html/FilesPageHtml.generated.h"
#include "html/FontsPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "html/js/jszip_minJs.generated.h"
@@ -164,6 +168,12 @@ void CrossPointWebServer::begin() {
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
// Font management endpoints
server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); });
server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); });
server->on("/api/fonts/upload", HTTP_POST, [this] { handleFontUpload(); }, [this] { handleFontUploadData(); });
server->on("/api/fonts/delete", HTTP_POST, [this] { handleFontDelete(); });
// OPDS server endpoints
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
@@ -1101,7 +1111,10 @@ void CrossPointWebServer::handleSettingsPage() const {
}
void CrossPointWebServer::handleGetSettings() const {
const auto& settings = getSettingsList();
// Pass the SD font registry so the fontFamily setting's enumStringValues
// includes SD-resident families — otherwise the web API only exposes the
// three built-in fonts.
const auto& settings = getSettingsList(&sdFontSystem.registry());
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
@@ -1136,8 +1149,14 @@ void CrossPointWebServer::handleGetSettings() const {
doc["value"] = static_cast<int>(s.valueGetter());
}
JsonArray options = doc["options"].to<JsonArray>();
for (const auto& opt : s.enumValues) {
options.add(I18N.get(opt));
if (!s.enumStringValues.empty()) {
for (const auto& opt : s.enumStringValues) {
options.add(opt);
}
} else {
for (const auto& opt : s.enumValues) {
options.add(I18N.get(opt));
}
}
break;
}
@@ -1197,7 +1216,7 @@ void CrossPointWebServer::handlePostSettings() {
return;
}
const auto& settings = getSettingsList();
const auto& settings = getSettingsList(&sdFontSystem.registry());
int applied = 0;
for (const auto& s : settings) {
@@ -1215,7 +1234,9 @@ void CrossPointWebServer::handlePostSettings() {
}
case SettingType::ENUM: {
const int val = doc[s.key].as<int>();
if (val >= 0 && val < static_cast<int>(s.enumValues.size())) {
const int maxVal = s.enumStringValues.empty() ? static_cast<int>(s.enumValues.size())
: static_cast<int>(s.enumStringValues.size());
if (val >= 0 && val < maxVal) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
} else if (s.valueSetter) {
@@ -1694,3 +1715,199 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
break;
}
}
// --- Font management handlers ---
void CrossPointWebServer::handleFontsPage() const {
sendHtmlContent(server.get(), FontsPageHtml, sizeof(FontsPageHtml));
LOG_DBG("WEB", "Served fonts page");
}
void CrossPointWebServer::handleFontList() const {
// Pick up any uploads/deletes that happened since the last reader load.
const_cast<SdCardFontSystem&>(sdFontSystem).refreshIfDirty();
const auto& families = sdFontSystem.registry().getFamilies();
JsonDocument doc;
JsonArray arr = doc["families"].to<JsonArray>();
doc["maxFamilies"] = SdCardFontRegistry::MAX_SD_FAMILIES;
for (const auto& family : families) {
JsonObject fObj = arr.add<JsonObject>();
fObj["name"] = family.name;
JsonArray sizes = fObj["sizes"].to<JsonArray>();
for (uint8_t s : family.availableSizes()) {
sizes.add(s);
}
JsonArray files = fObj["files"].to<JsonArray>();
for (const auto& file : family.files) {
JsonObject fileObj = files.add<JsonObject>();
// Extract filename from full path
const char* name = strrchr(file.path.c_str(), '/');
fileObj["name"] = name ? name + 1 : file.path.c_str();
// Stat the file for size
FsFile f;
if (Storage.openFileForRead("WEB", file.path.c_str(), f)) {
fileObj["size"] = static_cast<unsigned long>(f.size());
f.close();
} else {
fileObj["size"] = 0;
}
}
}
String json;
serializeJson(doc, json);
server->send(200, "application/json", json);
}
void CrossPointWebServer::handleFontUploadData() {
HTTPUpload& upload = server->upload();
switch (upload.status) {
case UPLOAD_FILE_START: {
esp_task_wdt_reset();
String family = server->arg("family");
fontUpload.valid = false;
fontUpload.magicChecked = false;
fontUpload.bytesWritten = 0;
fontUpload.bufferPos = 0;
if (!FontInstaller::isValidFamilyName(family.c_str())) {
LOG_ERR("WEB", "Invalid font family name: %s", family.c_str());
break;
}
String filename = upload.filename;
// Validate filename: rejects path traversal (../, /, \) and enforces
// a .cpfont basename of alphanumeric + hyphen + underscore. Without
// this an attacker could supply "../../.crosspoint/settings.json" as
// a "filename" and have it written outside the fonts directory.
if (!FontInstaller::isValidCpfontFilename(filename.c_str())) {
LOG_ERR("WEB", "Invalid font filename: %s", filename.c_str());
break;
}
fontUpload.familyName = family.c_str();
// Create a temporary FontInstaller for directory creation
FontInstaller installer(sdFontSystem.registry());
if (!installer.ensureFamilyDir(family.c_str())) {
LOG_ERR("WEB", "Failed to create font family dir");
break;
}
char path[128];
FontInstaller::buildFontPath(family.c_str(), filename.c_str(), path, sizeof(path));
fontUpload.filePath = path;
if (!Storage.openFileForWrite("WEB", path, fontUpload.file)) {
LOG_ERR("WEB", "Failed to open font file for write: %s", path);
break;
}
fontUpload.valid = true;
LOG_DBG("WEB", "Font upload started: %s -> %s", filename.c_str(), path);
break;
}
case UPLOAD_FILE_WRITE: {
if (!fontUpload.valid) break;
esp_task_wdt_reset();
// Validate magic bytes on first chunk only
if (!fontUpload.magicChecked && upload.currentSize >= 8) {
if (memcmp(upload.buf, "CPFONT\0\0", 8) != 0) {
LOG_ERR("WEB", "Invalid .cpfont magic bytes");
fontUpload.valid = false;
break;
}
fontUpload.magicChecked = true;
}
// Buffer writes for efficiency
size_t remaining = upload.currentSize;
const uint8_t* src = upload.buf;
while (remaining > 0) {
size_t space = FontUploadState::BUFFER_SIZE - fontUpload.bufferPos;
size_t chunk = (remaining < space) ? remaining : space;
memcpy(fontUpload.buffer.data() + fontUpload.bufferPos, src, chunk);
fontUpload.bufferPos += chunk;
src += chunk;
remaining -= chunk;
if (fontUpload.bufferPos >= FontUploadState::BUFFER_SIZE) {
fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos);
fontUpload.bytesWritten += fontUpload.bufferPos;
fontUpload.bufferPos = 0;
esp_task_wdt_reset();
}
}
break;
}
case UPLOAD_FILE_END: {
// Flush remaining buffer
if (fontUpload.valid && fontUpload.bufferPos > 0) {
fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos);
fontUpload.bytesWritten += fontUpload.bufferPos;
fontUpload.bufferPos = 0;
}
fontUpload.file.close();
if (!fontUpload.valid && !fontUpload.filePath.empty()) {
Storage.remove(fontUpload.filePath.c_str());
}
LOG_DBG("WEB", "Font upload end: valid=%d, %zu bytes", fontUpload.valid, fontUpload.bytesWritten);
break;
}
case UPLOAD_FILE_ABORTED: {
fontUpload.file.close();
if (!fontUpload.filePath.empty()) {
Storage.remove(fontUpload.filePath.c_str());
}
fontUpload.valid = false;
LOG_DBG("WEB", "Font upload aborted");
break;
}
}
}
void CrossPointWebServer::handleFontUpload() {
if (fontUpload.valid) {
sdFontSystem.markRegistryDirty();
server->send(200, "application/json", "{\"ok\":true}");
LOG_DBG("WEB", "Font upload complete: %s", fontUpload.filePath.c_str());
} else {
server->send(400, "application/json", "{\"error\":\"Invalid .cpfont file\"}");
}
}
void CrossPointWebServer::handleFontDelete() {
String body = server->arg("plain");
JsonDocument doc;
DeserializationError err = deserializeJson(doc, body);
if (err || !doc["family"].is<const char*>()) {
server->send(400, "application/json", "{\"error\":\"Invalid request\"}");
return;
}
const char* familyName = doc["family"];
FontInstaller installer(sdFontSystem.registry());
auto result = installer.deleteFamily(familyName);
if (result == FontInstaller::Error::OK) {
sdFontSystem.markRegistryDirty();
server->send(200, "application/json", "{\"ok\":true}");
LOG_DBG("WEB", "Deleted font family: %s", familyName);
} else {
server->send(500, "application/json", "{\"error\":\"Delete failed\"}");
LOG_ERR("WEB", "Failed to delete font family: %s", familyName);
}
}
+22
View File
@@ -108,6 +108,28 @@ class CrossPointWebServer {
void handleGetSettings() const;
void handlePostSettings();
// Font management handlers
void handleFontsPage() const;
void handleFontList() const;
void handleFontUpload();
void handleFontUploadData();
void handleFontDelete();
// Font upload state
struct FontUploadState {
FsFile file;
std::string familyName;
std::string filePath;
bool valid = false;
bool magicChecked = false;
size_t bytesWritten = 0;
static constexpr size_t BUFFER_SIZE = 4096;
std::vector<uint8_t> buffer;
size_t bufferPos = 0;
FontUploadState() { buffer.resize(BUFFER_SIZE); }
} fontUpload;
// OPDS server handlers
void handleGetOpdsServers() const;
void handlePostOpdsServer();
+1
View File
@@ -1465,6 +1465,7 @@
<a href="/">Home</a>
<a href="/files" class="active">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts">Fonts</a>
</div>
<div class="page-header">
+323
View File
@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader - Fonts</title>
<style>
:root {
--font-color: #333;
--bg: #f5f5f5;
--title-color: #2c3e50;
--card-bg: #FFF;
--label-color: #7f8c8d;
--border-color: #eee;
--accent-color: rgb(110, 154, 130);
--accent-hover-color: #5a8c73;
--danger-color: #e74c3c;
--danger-hover: #c0392b;
}
@media (prefers-color-scheme: dark) {
:root {
--font-color: #f5f5f5;
--bg: #333;
--title-color: #ecf0f1;
--card-bg: #444;
--label-color: #bdc3c7;
--border-color: #555;
color-scheme: dark;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg);
color: var(--font-color);
}
h1 {
color: var(--title-color);
border-bottom: 2px solid var(--accent-color);
padding-bottom: 10px;
}
h2 { color: var(--title-color); margin-top: 0; }
h3 { margin: 0 0 8px 0; }
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nav-links {
margin: 20px 0;
display: flex;
gap: 10px;
}
.nav-links a {
padding: 10px 20px;
color: var(--font-color);
text-decoration: none;
border-radius: 4px;
}
.nav-links a.active {
background-color: var(--accent-color);
color: white;
}
.nav-links a:not(.active):hover {
background-color: var(--accent-hover-color);
color: white;
}
.family {
border-bottom: 1px solid var(--border-color);
padding: 12px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.family:last-child { border-bottom: none; }
.family-info { flex: 1; }
.family-meta { color: var(--label-color); font-size: 0.9em; }
.btn {
padding: 6px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover { background: var(--danger-hover); }
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover { background: var(--accent-hover-color); }
.upload-form {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.upload-form input[type="text"] {
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg);
color: var(--font-color);
}
.upload-form input[type="file"] { flex: 1; min-width: 200px; }
#status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
display: none;
}
.status-ok { background: #d4edda; color: #155724; display: block !important; }
.status-err { background: #f8d7da; color: #721c24; display: block !important; }
.empty { color: var(--label-color); text-align: center; padding: 20px; }
</style>
</head>
<body>
<h1>📚 CrossPoint Reader</h1>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts" class="active">Fonts</a>
</div>
<div class="card">
<h2>Installed Fonts</h2>
<div id="families"><p class="empty">Loading...</p></div>
</div>
<div class="card">
<h2>Upload Font</h2>
<form class="upload-form" id="uploadForm">
<input type="file" id="fontFiles" webkitdirectory directory multiple required />
<button type="submit" class="btn btn-primary">Upload</button>
</form>
<p id="pickedInfo" class="family-meta" style="margin: 8px 0 0;"></p>
<div id="status"></div>
</div>
<script>
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB';
return bytes + ' B';
}
async function loadFonts() {
const el = document.getElementById('families');
try {
const res = await fetch('/api/fonts');
const data = await res.json();
// Build rows with DOM APIs and textContent so on-device family names
// (which can contain arbitrary characters) cannot break markup or
// execute script via innerHTML / inline onclick interpolation.
el.replaceChildren();
if (!data.families || data.families.length === 0) {
const p = document.createElement('p');
p.className = 'empty';
p.textContent = 'No fonts installed';
el.appendChild(p);
return;
}
for (const f of data.families) {
const row = document.createElement('div');
row.className = 'family';
const info = document.createElement('div');
info.className = 'family-info';
const h3 = document.createElement('h3');
h3.textContent = f.name;
info.appendChild(h3);
const meta = document.createElement('span');
meta.className = 'family-meta';
const sizes = (f.sizes || []).join(', ');
const filesSizes = (f.files || []).map(fi => formatSize(fi.size)).join(' + ');
meta.textContent = sizes + 'pt · ' + filesSizes;
info.appendChild(meta);
const btn = document.createElement('button');
btn.className = 'btn btn-danger';
btn.textContent = 'Delete';
// Capture name in the closure rather than interpolating into onclick.
const familyName = f.name;
btn.addEventListener('click', () => deleteFamily(familyName));
row.appendChild(info);
row.appendChild(btn);
el.appendChild(row);
}
} catch (e) {
el.replaceChildren();
const p = document.createElement('p');
p.className = 'empty';
p.textContent = 'Failed to load font list';
el.appendChild(p);
}
}
async function deleteFamily(name) {
if (!confirm('Delete font family "' + name + '"?')) return;
const status = document.getElementById('status');
status.className = '';
status.style.display = 'block';
status.textContent = 'Deleting ' + name + '...';
try {
const res = await fetch('/api/fonts/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({family: name})
});
if (res.ok) {
status.className = 'status-ok';
status.textContent = 'Deleted "' + name + '".';
} else {
status.className = 'status-err';
status.textContent = 'Failed to delete "' + name + '".';
}
} catch (err) {
status.className = 'status-err';
status.textContent = 'Delete error: ' + err.message;
}
await loadFonts();
}
// Derive family name from a .cpfont filename: take everything before the
// last '-' or '_' (that separator precedes the size suffix, e.g. Bookerly_12.cpfont).
function familyFromFilename(name) {
const stem = name.replace(/\.cpfont$/i, '');
const cut = Math.max(stem.lastIndexOf('-'), stem.lastIndexOf('_'));
return cut > 0 ? stem.slice(0, cut) : stem;
}
// Sanitize to match firmware's [A-Za-z0-9_-]+ pattern.
function sanitizeFamily(raw) {
return raw.replace(/[^A-Za-z0-9_-]/g, '_');
}
function cpfontFilesOnly(fileList) {
return Array.from(fileList).filter(f => /\.cpfont$/i.test(f.name));
}
document.getElementById('fontFiles').addEventListener('change', function() {
const info = document.getElementById('pickedInfo');
const files = cpfontFilesOnly(this.files);
if (files.length === 0) {
info.textContent = 'No .cpfont files found in the selected folder.';
return;
}
const family = sanitizeFamily(familyFromFilename(files[0].name));
info.textContent = files.length + ' file' + (files.length === 1 ? '' : 's') +
' → family "' + family + '"';
});
document.getElementById('uploadForm').addEventListener('submit', async function(e) {
e.preventDefault();
const status = document.getElementById('status');
const files = cpfontFilesOnly(document.getElementById('fontFiles').files);
if (files.length === 0) {
status.className = 'status-err';
status.style.display = 'block';
status.textContent = 'No .cpfont files selected.';
return;
}
// A directory picker may include files from multiple family subfolders.
// Reject that up front — otherwise files[0]'s family is silently reused
// for every upload, corrupting the install layout.
const families = [...new Set(files.map(f => sanitizeFamily(familyFromFilename(f.name))))];
if (families.length !== 1) {
status.className = 'status-err';
status.style.display = 'block';
status.textContent = 'Please select files from a single font family.';
return;
}
const family = families[0];
status.className = '';
status.style.display = 'block';
let uploaded = 0;
for (const file of files) {
status.textContent = 'Uploading ' + (uploaded + 1) + '/' + files.length + ': ' + file.name;
const formData = new FormData();
formData.append('family', family);
formData.append('file', file, file.name);
try {
const res = await fetch('/api/fonts/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!data.ok) {
status.className = 'status-err';
status.textContent = 'Failed on ' + file.name + ': ' + (data.error || 'unknown error');
await loadFonts();
return;
}
} catch (err) {
status.className = 'status-err';
status.textContent = 'Upload error on ' + file.name + ': ' + err.message;
await loadFonts();
return;
}
uploaded++;
}
status.className = 'status-ok';
status.textContent = 'Uploaded ' + uploaded + ' file' + (uploaded === 1 ? '' : 's') +
' to family "' + family + '".';
await loadFonts();
});
loadFonts();
</script>
</body>
</html>
+1
View File
@@ -104,6 +104,7 @@
<a href="/" class="active">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
<a href="/fonts">Fonts</a>
</div>
<div class="card">
+1
View File
@@ -285,6 +285,7 @@
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings" class="active">Settings</a>
<a href="/fonts">Fonts</a>
</div>
<div id="message" class="message"></div>