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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user