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>
434 lines
17 KiB
C++
434 lines
17 KiB
C++
#include "JsonSettingsIO.h"
|
|
|
|
#include <ArduinoJson.h>
|
|
#include <HalStorage.h>
|
|
#include <Logging.h>
|
|
#include <ObfuscationUtils.h>
|
|
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
#include "CrossPointSettings.h"
|
|
#include "CrossPointState.h"
|
|
#include "KOReaderCredentialStore.h"
|
|
#include "OpdsServerStore.h"
|
|
#include "RecentBooksStore.h"
|
|
#include "SettingsList.h"
|
|
#include "WifiCredentialStore.h"
|
|
|
|
// Convert legacy settings.
|
|
void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
|
|
switch (static_cast<CrossPointSettings::STATUS_BAR_MODE>(settings.statusBar)) {
|
|
case CrossPointSettings::NONE:
|
|
settings.statusBarChapterPageCount = 0;
|
|
settings.statusBarBookProgressPercentage = 0;
|
|
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
|
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
|
settings.statusBarBattery = 0;
|
|
break;
|
|
case CrossPointSettings::NO_PROGRESS:
|
|
settings.statusBarChapterPageCount = 0;
|
|
settings.statusBarBookProgressPercentage = 0;
|
|
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
|
settings.statusBarBattery = 1;
|
|
break;
|
|
case CrossPointSettings::BOOK_PROGRESS_BAR:
|
|
settings.statusBarChapterPageCount = 1;
|
|
settings.statusBarBookProgressPercentage = 0;
|
|
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
|
settings.statusBarBattery = 1;
|
|
break;
|
|
case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR:
|
|
settings.statusBarChapterPageCount = 1;
|
|
settings.statusBarBookProgressPercentage = 0;
|
|
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
|
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
|
settings.statusBarBattery = 0;
|
|
break;
|
|
case CrossPointSettings::CHAPTER_PROGRESS_BAR:
|
|
settings.statusBarChapterPageCount = 0;
|
|
settings.statusBarBookProgressPercentage = 1;
|
|
settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS;
|
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
|
settings.statusBarBattery = 1;
|
|
break;
|
|
case CrossPointSettings::FULL:
|
|
default:
|
|
settings.statusBarChapterPageCount = 1;
|
|
settings.statusBarBookProgressPercentage = 1;
|
|
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
|
settings.statusBarBattery = 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---- CrossPointState ----
|
|
|
|
bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) {
|
|
JsonDocument doc;
|
|
doc["openEpubPath"] = s.openEpubPath;
|
|
JsonArray recentArr = doc["recentSleepImages"].to<JsonArray>();
|
|
for (int i = 0; i < CrossPointState::SLEEP_RECENT_COUNT; i++) recentArr.add(s.recentSleepImages[i]);
|
|
doc["recentSleepPos"] = s.recentSleepPos;
|
|
doc["recentSleepFill"] = s.recentSleepFill;
|
|
doc["readerActivityLoadCount"] = s.readerActivityLoadCount;
|
|
doc["lastSleepFromReader"] = s.lastSleepFromReader;
|
|
|
|
String json;
|
|
serializeJson(doc, json);
|
|
return Storage.writeFile(path, json);
|
|
}
|
|
|
|
bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) {
|
|
JsonDocument doc;
|
|
auto error = deserializeJson(doc, json);
|
|
if (error) {
|
|
LOG_ERR("CPS", "JSON parse error: %s", error.c_str());
|
|
return false;
|
|
}
|
|
|
|
s.openEpubPath = doc["openEpubPath"] | std::string("");
|
|
memset(s.recentSleepImages, 0, sizeof(s.recentSleepImages));
|
|
JsonArrayConst recentArr = doc["recentSleepImages"];
|
|
const int actualCount = recentArr.isNull() ? 0
|
|
: std::min(static_cast<int>(recentArr.size()),
|
|
static_cast<int>(CrossPointState::SLEEP_RECENT_COUNT));
|
|
for (int i = 0; i < actualCount; i++) s.recentSleepImages[i] = recentArr[i] | static_cast<uint16_t>(0);
|
|
s.recentSleepPos = doc["recentSleepPos"] | static_cast<uint8_t>(0);
|
|
if (s.recentSleepPos >= CrossPointState::SLEEP_RECENT_COUNT)
|
|
s.recentSleepPos = actualCount > 0 ? s.recentSleepPos % CrossPointState::SLEEP_RECENT_COUNT : 0;
|
|
s.recentSleepFill = doc["recentSleepFill"] | static_cast<uint8_t>(0);
|
|
s.recentSleepFill = static_cast<uint8_t>(std::min(static_cast<int>(s.recentSleepFill), actualCount));
|
|
// Migrate legacy single-image field from old state.json (pre-recency-buffer).
|
|
// Only seeds the buffer if the new buffer is empty (fresh migration, not a resave).
|
|
if (s.recentSleepFill == 0 && !doc["lastSleepImage"].isNull()) {
|
|
const uint8_t legacy = doc["lastSleepImage"] | static_cast<uint8_t>(UINT8_MAX);
|
|
if (legacy != UINT8_MAX) s.pushRecentSleep(static_cast<uint16_t>(legacy));
|
|
}
|
|
s.readerActivityLoadCount = doc["readerActivityLoadCount"] | static_cast<uint8_t>(0);
|
|
s.lastSleepFromReader = doc["lastSleepFromReader"] | false;
|
|
return true;
|
|
}
|
|
|
|
// ---- CrossPointSettings ----
|
|
|
|
bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) {
|
|
JsonDocument doc;
|
|
|
|
for (const auto& info : getSettingsList()) {
|
|
if (!info.key) continue;
|
|
// Dynamic entries (KOReader etc.) are stored in their own files — skip.
|
|
if (!info.valuePtr && !info.stringOffset) continue;
|
|
|
|
if (info.stringOffset) {
|
|
const char* strPtr = (const char*)&s + info.stringOffset;
|
|
if (info.obfuscated) {
|
|
doc[std::string(info.key) + "_obf"] = obfuscation::obfuscateToBase64(strPtr);
|
|
} else {
|
|
doc[info.key] = strPtr;
|
|
}
|
|
} else {
|
|
doc[info.key] = s.*(info.valuePtr);
|
|
}
|
|
}
|
|
|
|
// Front button remap — managed by RemapFrontButtons sub-activity, not in SettingsList.
|
|
doc["frontButtonBack"] = s.frontButtonBack;
|
|
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.
|
|
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
|
|
|
String json;
|
|
serializeJson(doc, json);
|
|
return Storage.writeFile(path, json);
|
|
}
|
|
|
|
bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* needsResave) {
|
|
if (needsResave) *needsResave = false;
|
|
JsonDocument doc;
|
|
auto error = deserializeJson(doc, json);
|
|
if (error) {
|
|
LOG_ERR("CPS", "JSON parse error: %s", error.c_str());
|
|
return false;
|
|
}
|
|
|
|
auto clamp = [](uint8_t val, uint8_t maxVal, uint8_t def) -> uint8_t { return val < maxVal ? val : def; };
|
|
|
|
// Legacy migration: if statusBarChapterPageCount is absent this is a pre-refactor settings file.
|
|
// Populate s with migrated values now so the generic loop below picks them up as defaults and clamps them.
|
|
if (doc["statusBarChapterPageCount"].isNull()) {
|
|
applyLegacyStatusBarSettings(s);
|
|
}
|
|
|
|
for (const auto& info : getSettingsList()) {
|
|
if (!info.key) continue;
|
|
// Dynamic entries (KOReader etc.) are stored in their own files — skip.
|
|
if (!info.valuePtr && !info.stringOffset) continue;
|
|
|
|
if (info.stringOffset) {
|
|
const char* strPtr = (const char*)&s + info.stringOffset;
|
|
const std::string fieldDefault = strPtr; // current buffer = struct-initializer default
|
|
std::string val;
|
|
if (info.obfuscated) {
|
|
bool ok = false;
|
|
val = obfuscation::deobfuscateFromBase64(doc[std::string(info.key) + "_obf"] | "", &ok);
|
|
if (!ok || val.empty()) {
|
|
val = doc[info.key] | fieldDefault;
|
|
if (val != fieldDefault && needsResave) *needsResave = true;
|
|
}
|
|
} else {
|
|
val = doc[info.key] | fieldDefault;
|
|
}
|
|
char* destPtr = (char*)&s + info.stringOffset;
|
|
if (info.stringMaxLen == 0) {
|
|
LOG_ERR("CPS", "Misconfigured SettingInfo: stringMaxLen is 0 for key '%s'", info.key);
|
|
destPtr[0] = '\0';
|
|
if (needsResave) *needsResave = true;
|
|
continue;
|
|
}
|
|
strncpy(destPtr, val.c_str(), info.stringMaxLen - 1);
|
|
destPtr[info.stringMaxLen - 1] = '\0';
|
|
} else {
|
|
const uint8_t fieldDefault = s.*(info.valuePtr); // struct-initializer default, read before we overwrite it
|
|
uint8_t v = doc[info.key] | fieldDefault;
|
|
if (info.type == SettingType::ENUM) {
|
|
v = clamp(v, (uint8_t)info.enumValues.size(), fieldDefault);
|
|
} else if (info.type == SettingType::TOGGLE) {
|
|
v = clamp(v, (uint8_t)2, fieldDefault);
|
|
} else if (info.type == SettingType::VALUE) {
|
|
if (v < info.valueRange.min)
|
|
v = info.valueRange.min;
|
|
else if (v > info.valueRange.max)
|
|
v = info.valueRange.max;
|
|
}
|
|
s.*(info.valuePtr) = v;
|
|
}
|
|
}
|
|
|
|
// Front button remap — managed by RemapFrontButtons sub-activity, not in SettingsList.
|
|
using S = CrossPointSettings;
|
|
s.frontButtonBack =
|
|
clamp(doc["frontButtonBack"] | (uint8_t)S::FRONT_HW_BACK, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_BACK);
|
|
s.frontButtonConfirm = clamp(doc["frontButtonConfirm"] | (uint8_t)S::FRONT_HW_CONFIRM, S::FRONT_BUTTON_HARDWARE_COUNT,
|
|
S::FRONT_HW_CONFIRM);
|
|
s.frontButtonLeft =
|
|
clamp(doc["frontButtonLeft"] | (uint8_t)S::FRONT_HW_LEFT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_LEFT);
|
|
s.frontButtonRight =
|
|
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*>()));
|
|
}
|
|
|
|
LOG_DBG("CPS", "Settings loaded from file");
|
|
|
|
return true;
|
|
}
|
|
|
|
// ---- KOReaderCredentialStore ----
|
|
|
|
bool JsonSettingsIO::saveKOReader(const KOReaderCredentialStore& store, const char* path) {
|
|
JsonDocument doc;
|
|
doc["username"] = store.getUsername();
|
|
doc["password_obf"] = obfuscation::obfuscateToBase64(store.getPassword());
|
|
doc["serverUrl"] = store.getServerUrl();
|
|
doc["matchMethod"] = static_cast<uint8_t>(store.getMatchMethod());
|
|
|
|
String json;
|
|
serializeJson(doc, json);
|
|
return Storage.writeFile(path, json);
|
|
}
|
|
|
|
bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave) {
|
|
if (needsResave) *needsResave = false;
|
|
JsonDocument doc;
|
|
auto error = deserializeJson(doc, json);
|
|
if (error) {
|
|
LOG_ERR("KRS", "JSON parse error: %s", error.c_str());
|
|
return false;
|
|
}
|
|
|
|
store.username = doc["username"] | std::string("");
|
|
bool ok = false;
|
|
store.password = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok);
|
|
if (!ok || store.password.empty()) {
|
|
store.password = doc["password"] | std::string("");
|
|
if (!store.password.empty() && needsResave) *needsResave = true;
|
|
}
|
|
store.serverUrl = doc["serverUrl"] | std::string("");
|
|
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
|
store.matchMethod = static_cast<DocumentMatchMethod>(method);
|
|
|
|
LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", store.username.c_str());
|
|
return true;
|
|
}
|
|
|
|
// ---- WifiCredentialStore ----
|
|
|
|
bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) {
|
|
JsonDocument doc;
|
|
doc["lastConnectedSsid"] = store.getLastConnectedSsid();
|
|
|
|
JsonArray arr = doc["credentials"].to<JsonArray>();
|
|
for (const auto& cred : store.getCredentials()) {
|
|
JsonObject obj = arr.add<JsonObject>();
|
|
obj["ssid"] = cred.ssid;
|
|
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
|
|
}
|
|
|
|
String json;
|
|
serializeJson(doc, json);
|
|
return Storage.writeFile(path, json);
|
|
}
|
|
|
|
bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave) {
|
|
if (needsResave) *needsResave = false;
|
|
JsonDocument doc;
|
|
auto error = deserializeJson(doc, json);
|
|
if (error) {
|
|
LOG_ERR("WCS", "JSON parse error: %s", error.c_str());
|
|
return false;
|
|
}
|
|
|
|
store.lastConnectedSsid = doc["lastConnectedSsid"] | std::string("");
|
|
|
|
store.credentials.clear();
|
|
JsonArray arr = doc["credentials"].as<JsonArray>();
|
|
for (JsonObject obj : arr) {
|
|
if (store.credentials.size() >= store.MAX_NETWORKS) break;
|
|
WifiCredential cred;
|
|
cred.ssid = obj["ssid"] | std::string("");
|
|
bool ok = false;
|
|
cred.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
|
if (!ok || cred.password.empty()) {
|
|
cred.password = obj["password"] | std::string("");
|
|
if (!cred.password.empty() && needsResave) *needsResave = true;
|
|
}
|
|
store.credentials.push_back(cred);
|
|
}
|
|
|
|
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", store.credentials.size());
|
|
return true;
|
|
}
|
|
|
|
// ---- RecentBooksStore ----
|
|
|
|
bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* path) {
|
|
JsonDocument doc;
|
|
JsonArray arr = doc["books"].to<JsonArray>();
|
|
for (const auto& book : store.getBooks()) {
|
|
JsonObject obj = arr.add<JsonObject>();
|
|
obj["path"] = book.path;
|
|
obj["title"] = book.title;
|
|
obj["author"] = book.author;
|
|
obj["coverBmpPath"] = book.coverBmpPath;
|
|
}
|
|
|
|
String json;
|
|
serializeJson(doc, json);
|
|
return Storage.writeFile(path, json);
|
|
}
|
|
|
|
bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) {
|
|
JsonDocument doc;
|
|
auto error = deserializeJson(doc, json);
|
|
if (error) {
|
|
LOG_ERR("RBS", "JSON parse error: %s", error.c_str());
|
|
return false;
|
|
}
|
|
|
|
store.recentBooks.clear();
|
|
JsonArray arr = doc["books"].as<JsonArray>();
|
|
for (JsonObject obj : arr) {
|
|
if (store.getCount() >= 10) break;
|
|
RecentBook book;
|
|
book.path = obj["path"] | std::string("");
|
|
book.title = obj["title"] | std::string("");
|
|
book.author = obj["author"] | std::string("");
|
|
book.coverBmpPath = obj["coverBmpPath"] | std::string("");
|
|
store.recentBooks.push_back(book);
|
|
}
|
|
|
|
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount());
|
|
return true;
|
|
}
|
|
|
|
// ---- OpdsServerStore ----
|
|
// Follows the same save/load pattern as WifiCredentialStore above.
|
|
// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key).
|
|
|
|
bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) {
|
|
JsonDocument doc;
|
|
|
|
JsonArray arr = doc["servers"].to<JsonArray>();
|
|
for (const auto& server : store.getServers()) {
|
|
JsonObject obj = arr.add<JsonObject>();
|
|
obj["name"] = server.name;
|
|
obj["url"] = server.url;
|
|
obj["username"] = server.username;
|
|
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
|
|
}
|
|
|
|
String json;
|
|
serializeJson(doc, json);
|
|
return Storage.writeFile(path, json);
|
|
}
|
|
|
|
bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) {
|
|
if (needsResave) *needsResave = false;
|
|
JsonDocument doc;
|
|
auto error = deserializeJson(doc, json);
|
|
if (error) {
|
|
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
|
|
return false;
|
|
}
|
|
|
|
store.servers.clear();
|
|
JsonArray arr = doc["servers"].as<JsonArray>();
|
|
for (JsonObject obj : arr) {
|
|
if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break;
|
|
OpdsServer server;
|
|
server.name = obj["name"] | std::string("");
|
|
server.url = obj["url"] | std::string("");
|
|
server.username = obj["username"] | std::string("");
|
|
// Try the obfuscated key first; fall back to plaintext "password" for
|
|
// files written before obfuscation was added (or hand-edited JSON).
|
|
bool ok = false;
|
|
server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
|
if (!ok || server.password.empty()) {
|
|
server.password = obj["password"] | std::string("");
|
|
if (!server.password.empty() && needsResave) *needsResave = true;
|
|
}
|
|
store.servers.push_back(std::move(server));
|
|
}
|
|
|
|
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
|
return true;
|
|
}
|