feat: add SD card font support with on-device download and web management
Add a complete SD card font subsystem that enables users to install and use custom fonts beyond the three built-in families. This combines the back-end firmware support (#1327) with the font configuration, build pipeline, CI distribution, and user-facing management UI (#1392). Core font system: - Custom .cpfont binary format (v4) with multi-style support (regular, bold, italic, bold-italic) packed into a single file per size - On-demand glyph loading from SD card with two-pass prewarm rendering to bulk-read glyphs per page, achieving near-flash performance for Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower) - Persistent advance cache for layout measurement without SD I/O - Overflow ring buffer for glyph cache misses during rendering - Memory-conscious design: only advance tables kept in RAM; glyph bitmaps, kern tables, and ligatures loaded on demand from SD Font management: - On-device WiFi download from GitHub Releases with manifest-based discovery, install/update detection, and progress UI - Web interface font upload, listing, and deletion via /fonts page - Manual SD card copy to /fonts/ or /.fonts/ directories - Font selection integrated into Settings > Reader > Font Family Build pipeline: - Declarative YAML config (sd-fonts.yaml) as single source of truth for the 17-family font library (serif, sans, mono, accessibility) - Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with FreeType rasterization, class-based kerning, and ligature extraction - Parallel build orchestrator with variable font instance extraction - CI workflow publishing versioned + stable releases to a dedicated crosspoint-fonts repository with auto-incrementing revision tags - Centralized version constants (cpfont_version.py) shared across build tooling and CI, with firmware headers as manual sync points Additional fixes: - CJK characters no longer get hyphens inserted at line breaks - Advance table eliminates 30+ second stalls during CJK section indexing for paragraphs with >512 unique codepoints Closes #930 Co-authored-by: Zach Nelson <zach@zdnelson.com> Co-authored-by: Justin <itsthisjustin@users.noreply.github.com> Co-authored-by: jpirnay <jens@pirnay.com> Co-authored-by: mcrosson <kemonine@kemonine.info>
This commit is contained in:
committed by
Zach Nelson
co-authored by
Zach Nelson
Justin
jpirnay
mcrosson
parent
29fd29f537
commit
7993b2bb97
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
+101
-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},
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user