From 86229823c5adc97aaab550bfa08f17a18a8781d9 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 1 May 2026 10:06:38 +0200 Subject: [PATCH 01/21] Update German --- lib/I18n/translations/german.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 883a77f7..a90ab06a 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -359,7 +359,7 @@ STR_SLEEP_SCREEN_SET: "Standby-Bild aktualisiert!" STR_IMAGE_DISPLAY_BW: ">> S/W" STR_IMAGE_DISPLAY_GRAYSCALE: ">> Grau" STR_READER_BOOKMARKS: "Lesezeichen & Fußnoten" -STR_READER_OVERRIDES: "Buchspezifische Überschreibungen" +STR_READER_OVERRIDES: "Buchspezifische Einstellungen" STR_GLOBAL_BOOKMARKS: "Lesezeichen" STR_NO_GLOBAL_BOOKMARKS: "Noch keine Lesezeichen" STR_READER_UTILS: "Hilfsfunktionen" @@ -369,9 +369,9 @@ STR_NO_STARRED_PAGES: "Keine markierten Seiten" STR_RENAME: "Umbenennen" STR_PAGE_PREFIX: "S" STR_CAPTIVE_PORTAL_DETECTED: "Anmeldung erforderlich" -STR_CAPTIVE_PORTAL_HINT_1: "Netzwerk erfordert Browseranmeldung. Auf einem anderen Gerät," -STR_CAPTIVE_PORTAL_HINT_2: "öffnen Sie die untenstehende URL zur Autorisierung und drücken Sie dann OK." -STR_CAPTIVE_PORTAL_DONE: "Ich bin autorisiert" +STR_CAPTIVE_PORTAL_HINT_1: "Netzwerk erfordert Anmeldung. Authorisieren Sie dieses Gerät," +STR_CAPTIVE_PORTAL_HINT_2: "indem Sie die URL auf einem anderen Gerät öffnen und fahren dann fort." +STR_CAPTIVE_PORTAL_DONE: "Erledigt" STR_READER_TOOLS: "Werkzeuge" STR_READER_NAVIGATION: "Navigation" From 9a01bcab5fb0e9084adc867b55a2f00f2483e3f0 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 1 May 2026 10:19:50 +0200 Subject: [PATCH 02/21] Allow slightly unconvential header from xtcjs.app --- lib/Xtc/Xtc/XtcParser.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index 303f2251..10ba7c5d 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -11,6 +11,7 @@ #include #include +#include #include namespace xtc { @@ -193,10 +194,15 @@ XtcError XtcParser::readFirstPageInfo() { return XtcError::CORRUPTED_HEADER; } - // Verify the file is large enough to contain the full page table + // Verify the file is large enough to contain the full page table. + // Some encoders (e.g. xtcjs.app) place the page table at offset 0x30, overlapping + // the chapterOffset/padding tail of the documented 56-byte header. Accept any + // offset at or beyond the chapterOffset field (0x30) so long as it doesn't + // overlap fields we actually parse. + constexpr uint64_t kMinPageTableOffset = offsetof(XtcHeader, chapterOffset); const uint64_t fileSize = m_file.size64(); const uint64_t pageTableSize = static_cast(m_header.pageCount) * sizeof(PageTableEntry); - if (m_header.pageTableOffset < sizeof(XtcHeader) || m_header.pageTableOffset > fileSize || + if (m_header.pageTableOffset < kMinPageTableOffset || m_header.pageTableOffset > fileSize || pageTableSize > fileSize - m_header.pageTableOffset) { LOG_DBG("XTC", "Page table exceeds file bounds"); return XtcError::CORRUPTED_HEADER; From 2d97fd2a5aafce1c66fc903c6d248d978aac5d15 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Fri, 1 May 2026 20:31:05 +1200 Subject: [PATCH 03/21] fix: use correct server name for opds download --- src/activities/browser/OpdsBookBrowserActivity.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index b2be357c..f1ebd98d 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -398,7 +398,7 @@ void OpdsBookBrowserActivity::chooseBookFormat(const OpdsEntry& book) { selectedBookIndex = selectorIndex; formatSelectorIndex = 0; - formatSelectionLabels = buildOpdsFormatSelectionLabels(book.acquisitionLinks, SETTINGS.opdsServerUrl); + formatSelectionLabels = buildOpdsFormatSelectionLabels(book.acquisitionLinks, server.url); state = BrowserState::FORMAT_SELECTION; requestUpdate(); } @@ -413,7 +413,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcqu std::string downloadUrl = (acquisition.href.rfind("http", 0) == 0) ? acquisition.href - : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, acquisition.href); + : UrlUtils::buildUrl(server.url, acquisition.href); std::string filename = "/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + acquisition.fileExtension; From 7f121defa80c5e553dee6b43cf6a51f8dba57b60 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Fri, 1 May 2026 21:23:37 +1200 Subject: [PATCH 04/21] Remove legacy opds settings from web page --- src/SettingsList.h | 9 --------- src/activities/browser/OpdsBookBrowserActivity.cpp | 5 ++--- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/SettingsList.h b/src/SettingsList.h index f57fe0f6..ed1c4269 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -277,15 +277,6 @@ inline const std::vector list = { SettingInfo::Toggle(StrId::STR_KO_SYNC_ON_BOOK_CLOSE, &CrossPointSettings::koSyncOnBookClose, "koSyncOnBookClose", StrId::STR_KOREADER_SYNC), - // --- OPDS Browser (web-only, uses CrossPointSettings char arrays) --- - SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), - "opdsServerUrl", StrId::STR_OPDS_BROWSER), - SettingInfo::String(StrId::STR_USERNAME, SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername", - StrId::STR_OPDS_BROWSER), - SettingInfo::String(StrId::STR_PASSWORD, SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword", - StrId::STR_OPDS_BROWSER) - .withObfuscated(), - // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index f1ebd98d..2035217c 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -411,9 +411,8 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcqu downloadTotal = 0; requestUpdate(true); - std::string downloadUrl = (acquisition.href.rfind("http", 0) == 0) - ? acquisition.href - : UrlUtils::buildUrl(server.url, acquisition.href); + std::string downloadUrl = + (acquisition.href.rfind("http", 0) == 0) ? acquisition.href : UrlUtils::buildUrl(server.url, acquisition.href); std::string filename = "/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + acquisition.fileExtension; From fd111fbf98ff074cade26a5908d9561cfc4cbd01 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 1 May 2026 16:07:48 +0200 Subject: [PATCH 05/21] Fix screenshot button bleed through --- lib/I18n/translations/english.yaml | 3 ++- src/CrossPointSettings.h | 1 + src/SettingsList.h | 14 +++++++------- src/main.cpp | 15 ++++++++++++++- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index c214eb27..ac81a192 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -574,6 +574,7 @@ STR_BTN_ACT_PAGE_BACK_10: "Skip 10 Pages Back" STR_BTN_ACT_GO_HOME: "Go Home" STR_BTN_ACT_SLEEP: "Sleep" STR_BTN_ACT_FORCE_REFRESH: "Refresh Screen" +STR_BTN_ACT_FORCE_FAST_REFRESH: "Fast Refresh Screen" STR_BTN_ACT_OPEN_TOC: "Open Table of Contents" STR_BTN_ACT_OPEN_BOOKMARKS: "Open Bookmarks" STR_BTN_ACT_STAR_PAGE: "Star Page" @@ -599,4 +600,4 @@ STR_KB_HINT_UPPER_SECONDARY: "Hold SELECT for UPPERCASE or secondary char" STR_KB_HINT_LOWER_SECONDARY: "Hold SELECT for lowercase or secondary char" STR_KB_HINT_URL_SNIPPETS: "Press URL for snippets" STR_BIONIC_READING: "Bionic Reading" -STR_BTN_ACT_TOGGLE_BIONIC_READING: "Toggle Bionic Reading" \ No newline at end of file +STR_BTN_ACT_TOGGLE_BIONIC_READING: "Toggle Bionic Reading" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d970db53..1ae575d3 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -278,6 +278,7 @@ class CrossPointSettings { BTN_GO_HOME, BTN_SLEEP, BTN_FORCE_REFRESH, + BTN_FORCE_FAST_REFRESH, BTN_OPEN_TOC, BTN_OPEN_BOOKMARKS, BTN_STAR_PAGE, diff --git a/src/SettingsList.h b/src/SettingsList.h index ed1c4269..75d10ac2 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -141,13 +141,13 @@ inline const std::vector list = { // All entries share the same ordered action-label list; the submenu groups them behind // a single placeholder row in the device UI. // Shared action options (everything except the first "default" entry). -#define BTN_ACT_OPTIONS \ - StrId::STR_BTN_ACT_PAGE_FORWARD, StrId::STR_BTN_ACT_PAGE_BACK, StrId::STR_BTN_ACT_PAGE_FORWARD_10, \ - StrId::STR_BTN_ACT_PAGE_BACK_10, StrId::STR_BTN_ACT_GO_HOME, StrId::STR_BTN_ACT_SLEEP, \ - StrId::STR_BTN_ACT_FORCE_REFRESH, StrId::STR_BTN_ACT_OPEN_TOC, StrId::STR_BTN_ACT_OPEN_BOOKMARKS, \ - StrId::STR_BTN_ACT_STAR_PAGE, StrId::STR_BTN_ACT_FOOTNOTES, StrId::STR_BTN_ACT_NEXT_SECTION, \ - StrId::STR_BTN_ACT_PREV_SECTION, StrId::STR_BTN_ACT_EXIT_READER, StrId::STR_BTN_ACT_READER_MENU, \ - StrId::STR_BTN_ACT_TOGGLE_BIONIC_READING, StrId::STR_BTN_ACT_KOREADER_SYNC +#define BTN_ACT_OPTIONS \ + StrId::STR_BTN_ACT_PAGE_FORWARD, StrId::STR_BTN_ACT_PAGE_BACK, StrId::STR_BTN_ACT_PAGE_FORWARD_10, \ + StrId::STR_BTN_ACT_PAGE_BACK_10, StrId::STR_BTN_ACT_GO_HOME, StrId::STR_BTN_ACT_SLEEP, \ + StrId::STR_BTN_ACT_FORCE_REFRESH, StrId::STR_BTN_ACT_FORCE_FAST_REFRESH, StrId::STR_BTN_ACT_OPEN_TOC, \ + StrId::STR_BTN_ACT_OPEN_BOOKMARKS, StrId::STR_BTN_ACT_STAR_PAGE, StrId::STR_BTN_ACT_FOOTNOTES, \ + StrId::STR_BTN_ACT_NEXT_SECTION, StrId::STR_BTN_ACT_PREV_SECTION, StrId::STR_BTN_ACT_EXIT_READER, \ + StrId::STR_BTN_ACT_READER_MENU, StrId::STR_BTN_ACT_TOGGLE_BIONIC_READING, StrId::STR_BTN_ACT_KOREADER_SYNC // Back button: short=exit reader, double=ignore, long=go home SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortBack, {StrId::STR_BTN_DEF_EXIT_READER}, diff --git a/src/main.cpp b/src/main.cpp index d84a64e4..382b71d9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -337,6 +337,11 @@ void loop() { powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity } + // Power-hold timer for sleep. Hoisted above the screenshot block so the + // screenshot path can clear it and avoid a stale POWER press triggering sleep + // after the screenshot completes. + static unsigned long powerHoldStart = 0; + static bool screenshotButtonsReleased = true; if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) { if (screenshotButtonsReleased) { @@ -345,6 +350,10 @@ void loop() { RenderLock lock; ScreenshotUtil::takeScreenshot(renderer); } + // Discard the POWER+DOWN presses so they don't fire Short/Long events + // (e.g. page turn, sleep) once the user releases the combo. + buttonEventManager.drain(); + powerHoldStart = 0; } return; } else { @@ -364,7 +373,6 @@ void loop() { // (wake-up press) is never misinterpreted as a "go to sleep" press. // The power button long-press is not user-remappable, so this path always owns it. // Sleep mapped to other buttons is handled by the dispatcher's BTN_SLEEP case below. - static unsigned long powerHoldStart = 0; if (gpio.wasPressed(HalGPIO::BTN_POWER)) { powerHoldStart = millis(); LOG_DBG("MAIN", "loop: power button press detected (fresh edge)"); @@ -513,6 +521,11 @@ void loop() { renderer.displayBuffer(HalDisplay::HALF_REFRESH); break; } + case BA::BTN_FORCE_FAST_REFRESH: { + RenderLock lock; + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + break; + } case BA::BTN_OPEN_TOC: activityManager.dispatchButtonAction(BA::BTN_OPEN_TOC); break; From 67706edc1ae85d7dcf3c2d0c8a09c0df53f0c0bc Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 1 May 2026 16:52:34 +0200 Subject: [PATCH 06/21] Implement smarter section caching --- lib/Epub/Epub/Section.cpp | 143 +++++++++++++++++++++++++++++++++++++- lib/Epub/Epub/Section.h | 19 +++-- src/SettingsList.h | 10 +-- 3 files changed, 162 insertions(+), 10 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 3edb1e70..78b1632f 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -36,6 +36,134 @@ inline uint32_t paragraphLutEntryOffset(uint32_t lutStart, uint16_t page) { } } // namespace +#include + +namespace { +constexpr uint32_t FNV_PRIME = 0x01000193; // 16777619 +constexpr uint32_t FNV_OFFSET_BASIS = 0x811C9DC5; // 2166136261 + +uint32_t fnv1a(const uint8_t* data, size_t length) { + uint32_t hash = FNV_OFFSET_BASIS; + for (size_t i = 0; i < length; ++i) { + hash ^= data[i]; + hash *= FNV_PRIME; + } + return hash; +} +} // namespace + +uint32_t Section::calculatePropertyHash(int fontId, float lineCompression, bool extraParagraphSpacing, + uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, + bool hyphenationEnabled, bool embeddedStyle, bool bionicReadingEnabled, + uint8_t imageRendering) { + uint8_t buffer[64]; + size_t offset = 0; + + auto append = [&](const void* ptr, size_t size) { + memcpy(buffer + offset, ptr, size); + offset += size; + }; + + append(&fontId, sizeof(fontId)); + append(&lineCompression, sizeof(lineCompression)); + append(&extraParagraphSpacing, sizeof(extraParagraphSpacing)); + append(¶graphAlignment, sizeof(paragraphAlignment)); + append(&viewportWidth, sizeof(viewportWidth)); + append(&viewportHeight, sizeof(viewportHeight)); + append(&hyphenationEnabled, sizeof(hyphenationEnabled)); + append(&embeddedStyle, sizeof(embeddedStyle)); + append(&bionicReadingEnabled, sizeof(bionicReadingEnabled)); + append(&imageRendering, sizeof(imageRendering)); + + return fnv1a(buffer, offset); +} + +std::string Section::getSectionFilePath(uint32_t propertyHash) const { + char buf[32]; + snprintf(buf, sizeof(buf), "%d_%08x", spineIndex, propertyHash); + return epub->getCachePath() + "/sections/" + buf + ".bin"; +} + +std::string Section::getImageBasePath(uint32_t propertyHash) const { + char buf[32]; + snprintf(buf, sizeof(buf), "img_%d_%08x_", spineIndex, propertyHash); + return epub->getCachePath() + "/" + buf; +} + +struct SectionVariant { + std::string filename; + uint16_t date; + uint16_t time; +}; + +void Section::evictOldVariants() const { + // We keep up to 3 most recently accessed/modified variants to prevent SD card bloat + constexpr size_t MAX_VARIANTS = 3; + + std::string sectionsDir = epub->getCachePath() + "/sections"; + auto files = Storage.listFiles(sectionsDir.c_str(), 100); + + std::vector variants; + + // Find all cache variants belonging to this spineIndex + char prefix[16]; + snprintf(prefix, sizeof(prefix), "%d_", spineIndex); + size_t prefixLen = strlen(prefix); + + for (const auto& file : files) { + if (file.startsWith(prefix) && file.endsWith(".bin")) { + HalFile hf = Storage.open((sectionsDir + "/" + file.c_str()).c_str(), O_RDONLY); + if (hf) { + uint16_t md, mt; + if (hf.getModifyDateTime(&md, &mt)) { + variants.push_back({file.c_str(), md, mt}); + } else { + // If we can't get modified time, assume it's very old to evict it + variants.push_back({file.c_str(), 0, 0}); + } + } + } + } + + if (variants.size() <= MAX_VARIANTS) return; + + // Sort descending by modified date and time + std::sort(variants.begin(), variants.end(), [](const SectionVariant& a, const SectionVariant& b) { + if (a.date != b.date) return a.date > b.date; + return a.time > b.time; + }); + + // Delete everything after MAX_VARIANTS limit + for (size_t i = MAX_VARIANTS; i < variants.size(); ++i) { + std::string targetPath = sectionsDir + "/" + variants[i].filename; + Storage.remove(targetPath.c_str()); + LOG_DBG("SCT", "Evicted old section cache: %s", targetPath.c_str()); + + // Extract the hash to also clean up associated images + // Filename format: spineIndex_hash.bin + size_t underscore = variants[i].filename.find('_'); + size_t dot = variants[i].filename.find('.'); + if (underscore != std::string::npos && dot != std::string::npos && dot > underscore) { + std::string hashStr = variants[i].filename.substr(underscore + 1, dot - underscore - 1); + uint32_t parsedHash = strtoul(hashStr.c_str(), nullptr, 16); + if (parsedHash != 0 || hashStr == "00000000") { + std::string imgBasePath = getImageBasePath(parsedHash); + // Find and delete matching images + auto rootFiles = Storage.listFiles(epub->getCachePath().c_str(), 100); + size_t lastSlash = imgBasePath.find_last_of('/'); + std::string imgPrefix = (lastSlash != std::string::npos) ? imgBasePath.substr(lastSlash + 1) : imgBasePath; + + for (const auto& rf : rootFiles) { + if (rf.startsWith(imgPrefix.c_str())) { + Storage.remove((epub->getCachePath() + "/" + rf.c_str()).c_str()); + LOG_DBG("SCT", "Evicted old image cache: %s", rf.c_str()); + } + } + } + } + } +} + uint32_t Section::onPageComplete(std::unique_ptr page) { if (!file) { LOG_ERR("SCT", "File not open for writing page %d", pageCount); @@ -89,6 +217,11 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const bool bionicReadingEnabled, const uint8_t imageRendering) { + uint32_t propertyHash = + calculatePropertyHash(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, + viewportHeight, hyphenationEnabled, embeddedStyle, bionicReadingEnabled, imageRendering); + filePath = getSectionFilePath(propertyHash); + if (!Storage.openFileForRead("SCT", filePath, file)) { return false; } @@ -195,6 +328,11 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const bool bionicReadingEnabled, const uint8_t imageRendering, const std::function& progressFn) { + uint32_t propertyHash = + calculatePropertyHash(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, + viewportHeight, hyphenationEnabled, embeddedStyle, bionicReadingEnabled, imageRendering); + filePath = getSectionFilePath(propertyHash); + const uint32_t phaseTotalStart = millis(); const auto localPath = epub->getSpineItem(spineIndex).href; @@ -222,7 +360,10 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c // Derive the content base directory and image cache path prefix for the parser size_t lastSlash = localPath.find_last_of('/'); std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : ""; - std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_"; + std::string imageBasePath = getImageBasePath(propertyHash); + + // Evict old variants for this spine to keep cache size controlled + evictOldVariants(); CssParser* cssParser = nullptr; if (embeddedStyle) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index e4bdab5f..6c2f451b 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -38,15 +38,26 @@ class Section { // Caller is responsible for closing `outFile`. Returns false on any I/O or validation error. bool readParagraphLutHeader(FsFile& outFile, uint16_t& outCount, uint32_t& outLutStart) const; + // Calculates a stable hash for a given set of rendering properties. + // Used to suffix cache files so multiple variants can coexist safely without constant recompilation. + static uint32_t calculatePropertyHash(int fontId, float lineCompression, bool extraParagraphSpacing, + uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, + bool hyphenationEnabled, bool embeddedStyle, bool bionicReadingEnabled, + uint8_t imageRendering); + + // Computes the active file path for this section based on rendering properties + std::string getSectionFilePath(uint32_t propertyHash) const; + // Computes the image base path for extract images related to this specific section variant + std::string getImageBasePath(uint32_t propertyHash) const; + // Garbage collection: Keep only the most recent N variants per chapter + void evictOldVariants() const; + public: uint16_t pageCount = 0; int currentPage = 0; explicit Section(const std::shared_ptr& epub, const int spineIndex, GfxRenderer& renderer) - : epub(epub), - spineIndex(spineIndex), - renderer(renderer), - filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {} + : epub(epub), spineIndex(spineIndex), renderer(renderer) {} ~Section() = default; bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, diff --git a/src/SettingsList.h b/src/SettingsList.h index 75d10ac2..12b4503b 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -141,12 +141,12 @@ inline const std::vector list = { // All entries share the same ordered action-label list; the submenu groups them behind // a single placeholder row in the device UI. // Shared action options (everything except the first "default" entry). -#define BTN_ACT_OPTIONS \ - StrId::STR_BTN_ACT_PAGE_FORWARD, StrId::STR_BTN_ACT_PAGE_BACK, StrId::STR_BTN_ACT_PAGE_FORWARD_10, \ - StrId::STR_BTN_ACT_PAGE_BACK_10, StrId::STR_BTN_ACT_GO_HOME, StrId::STR_BTN_ACT_SLEEP, \ +#define BTN_ACT_OPTIONS \ + StrId::STR_BTN_ACT_PAGE_FORWARD, StrId::STR_BTN_ACT_PAGE_BACK, StrId::STR_BTN_ACT_PAGE_FORWARD_10, \ + StrId::STR_BTN_ACT_PAGE_BACK_10, StrId::STR_BTN_ACT_GO_HOME, StrId::STR_BTN_ACT_SLEEP, \ StrId::STR_BTN_ACT_FORCE_REFRESH, StrId::STR_BTN_ACT_FORCE_FAST_REFRESH, StrId::STR_BTN_ACT_OPEN_TOC, \ - StrId::STR_BTN_ACT_OPEN_BOOKMARKS, StrId::STR_BTN_ACT_STAR_PAGE, StrId::STR_BTN_ACT_FOOTNOTES, \ - StrId::STR_BTN_ACT_NEXT_SECTION, StrId::STR_BTN_ACT_PREV_SECTION, StrId::STR_BTN_ACT_EXIT_READER, \ + StrId::STR_BTN_ACT_OPEN_BOOKMARKS, StrId::STR_BTN_ACT_STAR_PAGE, StrId::STR_BTN_ACT_FOOTNOTES, \ + StrId::STR_BTN_ACT_NEXT_SECTION, StrId::STR_BTN_ACT_PREV_SECTION, StrId::STR_BTN_ACT_EXIT_READER, \ StrId::STR_BTN_ACT_READER_MENU, StrId::STR_BTN_ACT_TOGGLE_BIONIC_READING, StrId::STR_BTN_ACT_KOREADER_SYNC // Back button: short=exit reader, double=ignore, long=go home From c88e6f3170f9afea056e679f52a4e185162f8f5a Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 1 May 2026 16:53:41 +0200 Subject: [PATCH 07/21] Increase variants --- lib/Epub/Epub/Section.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 78b1632f..806433a8 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -97,8 +97,8 @@ struct SectionVariant { }; void Section::evictOldVariants() const { - // We keep up to 3 most recently accessed/modified variants to prevent SD card bloat - constexpr size_t MAX_VARIANTS = 3; + // We keep up to 5 most recently accessed/modified variants to prevent SD card bloat + constexpr size_t MAX_VARIANTS = 5; std::string sectionsDir = epub->getCachePath() + "/sections"; auto files = Storage.listFiles(sectionsDir.c_str(), 100); From 913b7e5f8b98823cacc06abdec6c0ca3aca52180 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 15:54:40 +1200 Subject: [PATCH 08/21] Avoid OOM during prewarm by loading only ligature data, not kern pairs This is particularly important when using SD fonts with many kern pairs --- lib/EpdFont/SdCardFont.cpp | 63 +++++++++++++++++++++++--------------- lib/EpdFont/SdCardFont.h | 5 +-- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index b493cf1c..ea88e8c3 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -66,11 +66,12 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) { void SdCardFont::freeStyleKernLigatureData(PerStyle& s) { delete[] s.kernLeftClasses; s.kernLeftClasses = nullptr; + s.kernClassesLoaded = false; delete[] s.kernRightClasses; s.kernRightClasses = nullptr; delete[] s.ligaturePairs; s.ligaturePairs = nullptr; - s.kernLigLoaded = false; + s.ligLoaded = false; } void SdCardFont::freeStyleMiniKern(PerStyle& s) { @@ -134,14 +135,17 @@ void SdCardFont::applyKernLigaturePointers(const PerStyle& s, EpdFontData& data) data.ligaturePairCount = s.header.ligaturePairCount; } -bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { - if (s.kernLigLoaded) return true; - bool hasKern = s.header.kernLeftEntryCount > 0; - bool hasLig = s.header.ligaturePairCount > 0; - if (!hasKern && !hasLig) { - s.kernLigLoaded = true; - return true; - } +bool SdCardFont::loadStyleKernLigatureData(PerStyle& s, bool ligatureOnly) { + // During metadata-only (layout) prewarms, skip the kern class tables: the kern + // matrix is never built at layout time so getKerning() returns 0 regardless. + // Skipping them saves ~4KB per style (~17KB total for 4 styles), preventing OOM + // on low-heap devices when long paragraphs try to grow their word vector. + const bool wantKern = !ligatureOnly && s.header.kernLeftEntryCount > 0; + const bool wantLig = s.header.ligaturePairCount > 0; + + const bool kernDone = !wantKern || s.kernClassesLoaded; + const bool ligDone = !wantLig || s.ligLoaded; + if (kernDone && ligDone) return true; FsFile file; if (!Storage.openFileForRead("SDCF", filePath_, file)) { @@ -149,7 +153,7 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { return false; } - if (hasKern) { + if (wantKern && !s.kernClassesLoaded) { // Load only the small class-lookup tables (~3KB each). The full matrix // (~36KB contiguous for Literata) is built per-page from SD in // buildMiniKernMatrix(). @@ -179,9 +183,10 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { file.close(); return false; } + s.kernClassesLoaded = true; } - if (hasLig) { + if (wantLig && !s.ligLoaded) { s.ligaturePairs = new (std::nothrow) EpdLigaturePair[s.header.ligaturePairCount]; if (!s.ligaturePairs) { LOG_ERR("SDCF", "Failed to allocate ligature pairs"); @@ -202,19 +207,21 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { file.close(); return false; } + s.ligLoaded = true; + + // Make ligatures visible to the stub (used when no mini data built yet). + // Kern stays nullptr on the stub — it is only wired in miniData via + // applyKernLigaturePointers() after buildMiniKernMatrix() runs. + s.stubData.ligaturePairs = s.ligaturePairs; + s.stubData.ligaturePairCount = s.header.ligaturePairCount; } file.close(); - s.kernLigLoaded = true; - - // Make ligatures visible to the stub (used when no mini data built yet). - // Kern stays nullptr on the stub — it is only wired in miniData via - // applyKernLigaturePointers() after buildMiniKernMatrix() runs. - s.stubData.ligaturePairs = s.ligaturePairs; - s.stubData.ligaturePairCount = s.header.ligaturePairCount; - - LOG_DBG("SDCF", "Kern classes + lig loaded: kernL=%u, kernR=%u, ligs=%u", s.header.kernLeftEntryCount, - s.header.kernRightEntryCount, s.header.ligaturePairCount); + LOG_DBG("SDCF", "Kern/lig loaded: kernL=%u kernR=%u ligs=%u ligOnly=%d", + s.kernClassesLoaded ? s.header.kernLeftEntryCount : 0u, + s.kernClassesLoaded ? s.header.kernRightEntryCount : 0u, + s.ligLoaded ? s.header.ligaturePairCount : 0u, + ligatureOnly); return true; } @@ -653,7 +660,7 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn if (!(styleMask & (1 << si)) || !styles_[si].present) continue; auto& s = styles_[si]; - loadStyleKernLigatureData(s); + loadStyleKernLigatureData(s, /*ligatureOnly=*/true); if (s.ligaturePairs && s.header.ligaturePairCount > 0) { for (uint8_t li = 0; li < s.header.ligaturePairCount && cpCount < MAX_PAGE_GLYPHS; li++) { uint32_t leftCp = s.ligaturePairs[li].pair >> 16; @@ -1143,7 +1150,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 kernLigOk = buildMiniKernMatrix(s, codepoints, cpCount); } } else if (loadKernLigatureData) { - loadStyleKernLigatureData(s); + loadStyleKernLigatureData(s, /*ligatureOnly=*/true); // Don't set kernLigOk → mini kern matrix stays null on miniData, but // ligatures are still resident on stubData (set in loadStyleKernLigatureData). } @@ -1161,7 +1168,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 if (kernLigOk) { // Full prewarm: wire mini kern matrix + class tables + ligatures. applyKernLigaturePointers(s, s.miniData); - } else if (loadKernLigatureData && s.kernLigLoaded) { + } else if (loadKernLigatureData && s.ligLoaded) { // Layout-only prewarm: wire ligatures so applyLigatures() works (e.g. "fi" // measures correctly). Skip the kern matrix — getKerning() returns 0 // cleanly when kernMatrix is null. Per-pair kern is applied at render time. @@ -1173,6 +1180,8 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 s.epdFont.data = &s.miniData; s.miniMode = metadataOnly ? PerStyle::MiniMode::METADATA : PerStyle::MiniMode::FULL; + LOG_DBG("SDCF", "prewarmStyle %u: mode→%s glyphs=%u bitmap=%p", + styleIdx, metadataOnly ? "METADATA" : "FULL", validCount, s.miniBitmap); // Accumulate stats stats_.sdReadTimeMs += sdTime; @@ -1236,6 +1245,10 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { const auto& s = self->styles_[styleIdx]; if (!s.fullIntervals) return nullptr; + // Diagnostic: log first miss per codepoint+style to show why it bypassed prewarm + LOG_DBG("SDCF", "onGlyphMiss: U+%04X style %u miniMode=%u miniIntervals=%u bitmap=%p", + codepoint, styleIdx, (uint8_t)s.miniMode, s.miniIntervalCount, s.miniBitmap); + // Check overflow cache first (matching both codepoint and style) for (uint32_t i = 0; i < self->overflowCount_; i++) { if (self->overflow_[i].codepoint == codepoint && self->overflow_[i].styleIdx == styleIdx) { @@ -1309,6 +1322,8 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { // All reads succeeded — commit to slot (evict old entry if at capacity) if (wasAtCapacity) { + LOG_DBG("SDCF", "Overflow: evicting U+%04X style %u from slot %u", + self->overflow_[slot].codepoint, self->overflow_[slot].styleIdx, slot); delete[] self->overflow_[slot].bitmap; } self->overflow_[slot].glyph = tempGlyph; diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index c7671a0f..597cbf06 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -118,7 +118,8 @@ class SdCardFont { EpdKernClassEntry* kernLeftClasses = nullptr; EpdKernClassEntry* kernRightClasses = nullptr; EpdLigaturePair* ligaturePairs = nullptr; - bool kernLigLoaded = false; + bool ligLoaded = false; ///< ligaturePairs resident + bool kernClassesLoaded = false; ///< kernLeft/RightClasses resident (skipped during metadata-only prewarm) // Stub EpdFontData returned when not prewarmed EpdFontData stubData{}; @@ -204,7 +205,7 @@ class SdCardFont { void freeStyleAll(PerStyle& s); void freeStyleKernLigatureData(PerStyle& s); void freeStyleMiniKern(PerStyle& s); - bool loadStyleKernLigatureData(PerStyle& s); + bool loadStyleKernLigatureData(PerStyle& s, bool ligatureOnly = false); bool buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount); void applyKernLigaturePointers(const PerStyle& s, EpdFontData& data) const; void applyGlyphMissCallback(uint8_t styleIdx); From 5928f32364543ee29ddac83d4175aa4b2eb16032 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 16:01:45 +1200 Subject: [PATCH 09/21] Avoid OOM diring HTML parsing by reducing max block size and freeing unneeded buffers --- lib/Epub/Epub/ParsedText.cpp | 9 ++++++++- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 11 ++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 61a6a210..bc869102 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -355,12 +355,19 @@ void ParsedText::layoutAndExtractLines( } } - // Remove consumed words so size() reflects only remaining words + // Remove consumed words so size() reflects only remaining words, then + // release excess capacity. Without shrink_to_fit the vector retains a + // large allocation from before the flush; the next paragraph fills it + // back up and eventually needs an even larger contiguous realloc. if (lineCount > 0) { const size_t consumed = lineBreakIndices[lineCount - 1]; words.erase(words.begin(), words.begin() + consumed); wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed); wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed); + words.shrink_to_fit(); + wordStyles.shrink_to_fit(); + wordContinues.shrink_to_fit(); + isContinuation_ = !includeLastLine; } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index bfa5ad9a..dfe7d664 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1207,11 +1207,12 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char self->partWordBuffer[self->partWordBufferIndex++] = s[i]; } - // If we have > 750 words buffered up, perform the layout and consume out all but the last line - // There should be enough here to build out 1-2 full pages and doing this will free up a lot of - // memory. - // Spotted when reading Intermezzo, there are some really long text blocks in there. - if (self->currentTextBlock->size() > 750) { + // Flush when words approach the doubling boundary that would require a + // large contiguous realloc. 96 fires before capacity reaches 128 + // (the next doubling after 64), keeping the realloc below 1.5KB and + // releasing excess capacity via shrink_to_fit in layoutAndExtractLines. + // The original 750-word threshold was too late for low-heap devices. + if (self->currentTextBlock->size() > 96) { LOG_DBG("EHP", "Text block too long, splitting into multiple pages"); const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth) From 40c3ecbada054278b376228e52a7ca20236d60c6 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 16:05:30 +1200 Subject: [PATCH 10/21] When a large paragraph is split across blocks, don't treat the continuation as a new paragraph --- lib/Epub/Epub/ParsedText.cpp | 10 +++++++--- lib/Epub/Epub/ParsedText.h | 2 ++ lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 16 ++++++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index bc869102..0987aa16 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -215,9 +215,13 @@ void ParsedText::layoutAndExtractLines( } // Apply fixed transforms before any per-line layout work. - applyParagraphIndent(); - if (bionicReadingEnabled) { - applyBionicReadingTransform(); + // Skip on continuation flushes: the words are mid-paragraph and have + // already been transformed by the initial layoutAndExtractLines call. + if (!isContinuation_) { + applyParagraphIndent(); + if (bionicReadingEnabled) { + applyBionicReadingTransform(); + } } // Ensure SD card font glyph metrics are loaded before measuring word widths. diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 2d94ed09..7b19db4c 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -27,6 +27,7 @@ class ParsedText { bool extraParagraphSpacing; bool hyphenationEnabled; bool bionicReadingEnabled; + bool isContinuation_ = false; ///< true after an intermediate flush; suppresses re-applying indent/transforms void applyParagraphIndent(); void applyBionicReadingTransform(); @@ -74,6 +75,7 @@ class ParsedText { BlockStyle& getBlockStyle() { return blockStyle; } size_t size() const { return words.size(); } bool isEmpty() const { return words.empty(); } + bool isContinuation() const { return isContinuation_; } void layoutAndExtractLines( const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, const std::function, bool, bool)>& processLine, diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index dfe7d664..231f30fa 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1592,13 +1592,17 @@ void ChapterHtmlSlimParser::makePages() { const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; - // Apply top spacing before the paragraph (stored in pixels) + // Apply top spacing before the paragraph — skip for continuation fragments + // (words left over after an intermediate flush): the top margin was already + // applied before the first set of lines from this logical paragraph. const BlockStyle& blockStyle = currentTextBlock->getBlockStyle(); - if (blockStyle.marginTop > 0) { - currentPageNextY += blockStyle.marginTop; - } - if (blockStyle.paddingTop > 0) { - currentPageNextY += blockStyle.paddingTop; + if (!currentTextBlock->isContinuation()) { + if (blockStyle.marginTop > 0) { + currentPageNextY += blockStyle.marginTop; + } + if (blockStyle.paddingTop > 0) { + currentPageNextY += blockStyle.paddingTop; + } } // Calculate effective width accounting for horizontal margins/padding From 8152204985548f44491a96db105a2c825202ba3b Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 16:40:23 +1200 Subject: [PATCH 11/21] Fix formatting This is particularly important when using SD fonts with many kern pairs --- lib/EpdFont/SdCardFont.cpp | 15 +++++++-------- lib/EpdFont/SdCardFont.h | 4 ++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index ea88e8c3..a07660b4 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -219,8 +219,7 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s, bool ligatureOnly) { file.close(); LOG_DBG("SDCF", "Kern/lig loaded: kernL=%u kernR=%u ligs=%u ligOnly=%d", s.kernClassesLoaded ? s.header.kernLeftEntryCount : 0u, - s.kernClassesLoaded ? s.header.kernRightEntryCount : 0u, - s.ligLoaded ? s.header.ligaturePairCount : 0u, + s.kernClassesLoaded ? s.header.kernRightEntryCount : 0u, s.ligLoaded ? s.header.ligaturePairCount : 0u, ligatureOnly); return true; } @@ -1180,8 +1179,8 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 s.epdFont.data = &s.miniData; s.miniMode = metadataOnly ? PerStyle::MiniMode::METADATA : PerStyle::MiniMode::FULL; - LOG_DBG("SDCF", "prewarmStyle %u: mode→%s glyphs=%u bitmap=%p", - styleIdx, metadataOnly ? "METADATA" : "FULL", validCount, s.miniBitmap); + LOG_DBG("SDCF", "prewarmStyle %u: mode→%s glyphs=%u bitmap=%p", styleIdx, metadataOnly ? "METADATA" : "FULL", + validCount, s.miniBitmap); // Accumulate stats stats_.sdReadTimeMs += sdTime; @@ -1246,8 +1245,8 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { if (!s.fullIntervals) return nullptr; // Diagnostic: log first miss per codepoint+style to show why it bypassed prewarm - LOG_DBG("SDCF", "onGlyphMiss: U+%04X style %u miniMode=%u miniIntervals=%u bitmap=%p", - codepoint, styleIdx, (uint8_t)s.miniMode, s.miniIntervalCount, s.miniBitmap); + LOG_DBG("SDCF", "onGlyphMiss: U+%04X style %u miniMode=%u miniIntervals=%u bitmap=%p", codepoint, styleIdx, + (uint8_t)s.miniMode, s.miniIntervalCount, s.miniBitmap); // Check overflow cache first (matching both codepoint and style) for (uint32_t i = 0; i < self->overflowCount_; i++) { @@ -1322,8 +1321,8 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { // All reads succeeded — commit to slot (evict old entry if at capacity) if (wasAtCapacity) { - LOG_DBG("SDCF", "Overflow: evicting U+%04X style %u from slot %u", - self->overflow_[slot].codepoint, self->overflow_[slot].styleIdx, slot); + LOG_DBG("SDCF", "Overflow: evicting U+%04X style %u from slot %u", self->overflow_[slot].codepoint, + self->overflow_[slot].styleIdx, slot); delete[] self->overflow_[slot].bitmap; } self->overflow_[slot].glyph = tempGlyph; diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 597cbf06..6776ce34 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -118,8 +118,8 @@ class SdCardFont { EpdKernClassEntry* kernLeftClasses = nullptr; EpdKernClassEntry* kernRightClasses = nullptr; EpdLigaturePair* ligaturePairs = nullptr; - bool ligLoaded = false; ///< ligaturePairs resident - bool kernClassesLoaded = false; ///< kernLeft/RightClasses resident (skipped during metadata-only prewarm) + bool ligLoaded = false; ///< ligaturePairs resident + bool kernClassesLoaded = false; ///< kernLeft/RightClasses resident (skipped during metadata-only prewarm) // Stub EpdFontData returned when not prewarmed EpdFontData stubData{}; From c04ec25dc78c257c66e3c71fba0f6712305be798 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 16:50:49 +1200 Subject: [PATCH 12/21] Address review comment: also suppress CSS styling on continuation --- lib/Epub/Epub/ParsedText.cpp | 70 ++++++++++++++---------------------- lib/Epub/Epub/ParsedText.h | 11 +++--- 2 files changed, 34 insertions(+), 47 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 0987aa16..d265f9d6 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -246,6 +246,15 @@ void ParsedText::layoutAndExtractLines( } const int pageWidth = viewportWidth; + + // Compute firstLineIndent once here so all layout helpers use the same value. + // On a continuation flush the remaining words are mid-paragraph, so no indent. + const int firstLineIndent = + !isContinuation_ && blockStyle.textIndentDefined && + (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) + ? std::min(std::max(static_cast(blockStyle.textIndent), -(pageWidth - 1)), pageWidth - 1) + : 0; + auto wordWidths = calculateWordWidths(renderer, fontId); std::vector lineBreakIndices; @@ -256,9 +265,9 @@ void ParsedText::layoutAndExtractLines( // Use greedy layout that can split words mid-loop when a hyphenated prefix fits. lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, lineEndsWithHyphenatedWord, - splitPrefixWordIndexes, splitInsertedHyphen); + splitPrefixWordIndexes, splitInsertedHyphen, firstLineIndent); } else { - lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues); + lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, firstLineIndent); lineEndsWithHyphenatedWord.assign(lineBreakIndices.size(), false); splitPrefixWordIndexes.assign(lineBreakIndices.size(), -1); splitInsertedHyphen.assign(lineBreakIndices.size(), false); @@ -268,7 +277,7 @@ void ParsedText::layoutAndExtractLines( for (size_t i = 0; i < lineCount; ++i) { const bool lineEndedWithHyphenation = i < lineEndsWithHyphenatedWord.size() ? lineEndsWithHyphenatedWord[i] : false; const auto result = extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, - fontId, lineEndedWithHyphenation, false); + fontId, lineEndedWithHyphenation, false, firstLineIndent); if (result == LineProcessResult::RetryWithoutHyphenation && lineEndedWithHyphenation) { const size_t lineStart = i > 0 ? lineBreakIndices[i - 1] : 0; @@ -314,7 +323,8 @@ void ParsedText::layoutAndExtractLines( // Keep previous lines fixed; recompute only this specific line without hyphenation. // Suppression is intentionally line-local. const size_t retryBreak = - computeSingleLineBreakNoHyphen(renderer, fontId, pageWidth, wordWidths, wordContinues, lineStart); + computeSingleLineBreakNoHyphen(renderer, fontId, pageWidth, wordWidths, wordContinues, lineStart, + firstLineIndent); lineBreakIndices.resize(i + 1); lineEndsWithHyphenatedWord.resize(i + 1); @@ -334,7 +344,7 @@ void ParsedText::layoutAndExtractLines( LOG_DBG("PTX", "Rerendering line %u with hyphenation suppressed, retry attempt: %s", static_cast(i), retryPreview.c_str()); extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId, false, - true); + true, firstLineIndent); // Resume regular hyphenation from the first word after the retried line. const size_t resumeIndex = lineBreakIndices[i]; @@ -387,20 +397,12 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere } std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, - std::vector& wordWidths, std::vector& continuesVec) { + std::vector& wordWidths, std::vector& continuesVec, + const int firstLineIndent) { if (words.empty()) { return {}; } - // Calculate first line indent (only for left/justified text). - // Explicit CSS text-indent always applies — author intent overrides the extraParagraphSpacing - // toggle. Only the implicit EmSpace fallback in applyParagraphIndent() is gated on it. - const int firstLineIndent = - blockStyle.textIndentDefined && - (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) - ? std::min(std::max(static_cast(blockStyle.textIndent), -(pageWidth - 1)), pageWidth - 1) - : 0; - // Ensure any word that would overflow even as the first entry on a line is split using fallback hyphenation. for (size_t i = 0; i < wordWidths.size(); ++i) { // First word needs to fit in reduced width if there's an indent @@ -522,18 +524,13 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c size_t ParsedText::computeSingleLineBreakNoHyphen(const GfxRenderer& renderer, const int fontId, const int pageWidth, const std::vector& wordWidths, const std::vector& continuesVec, - const size_t lineStartIndex) const { + const size_t lineStartIndex, const int firstLineIndent) const { // One-line non-hyphenating breaker used by the page-boundary retry path. if (lineStartIndex >= wordWidths.size()) { return lineStartIndex; } - const int firstLineIndent = - lineStartIndex == 0 && blockStyle.textIndentDefined && - (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) - ? std::min(std::max(static_cast(blockStyle.textIndent), -(pageWidth - 1)), pageWidth - 1) - : 0; - const int effectivePageWidth = pageWidth - firstLineIndent; + const int effectivePageWidth = pageWidth - (lineStartIndex == 0 ? firstLineIndent : 0); size_t currentIndex = lineStartIndex; int lineWidth = 0; @@ -672,15 +669,8 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r std::vector& continuesVec, std::vector& lineEndsWithHyphenatedWord, std::vector& splitPrefixWordIndexes, - std::vector& splitInsertedHyphen) { - // Calculate first line indent (only for left/justified text). - // Explicit CSS text-indent always applies — author intent overrides the extraParagraphSpacing - // toggle. Only the implicit EmSpace fallback in applyParagraphIndent() is gated on it. - const int firstLineIndent = - blockStyle.textIndentDefined && - (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) - ? std::min(std::max(static_cast(blockStyle.textIndent), -(pageWidth - 1)), pageWidth - 1) - : 0; + std::vector& splitInsertedHyphen, + const int firstLineIndent) { // Pre-compute inter-word gaps to avoid repeated codepoint scanning and renderer // calls in the inner loop. When hyphenateWordAtIndex inserts a new word, we insert @@ -960,20 +950,14 @@ ParsedText::LineProcessResult ParsedText::extractLine( const std::vector& continuesVec, const std::vector& lineBreakIndices, const std::function, bool, bool)>& processLine, const GfxRenderer& renderer, const int fontId, const bool lineEndsWithHyphenatedWord, - const bool suppressHyphenationRetry) { + const bool suppressHyphenationRetry, const int firstLineIndent) { const size_t lineBreak = lineBreakIndices[breakIndex]; const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; const size_t lineWordCount = lineBreak - lastBreakAt; - // Calculate first line indent (only for left/justified text). - // Explicit CSS text-indent always applies — author intent overrides the extraParagraphSpacing - // toggle. Only the implicit EmSpace fallback in applyParagraphIndent() is gated on it. - const bool isFirstLine = breakIndex == 0; - const int firstLineIndent = - isFirstLine && blockStyle.textIndentDefined && - (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) - ? std::min(std::max(static_cast(blockStyle.textIndent), -(pageWidth - 1)), pageWidth - 1) - : 0; + // Apply indent only to line 0 of the layout pass; firstLineIndent is already + // 0 for continuation flushes (computed once in layoutAndExtractLines). + const int lineIndent = (breakIndex == 0) ? firstLineIndent : 0; // Calculate total word width for this line, count actual word gaps, // and accumulate total natural gap widths (including space kerning adjustments). @@ -1000,7 +984,7 @@ ParsedText::LineProcessResult ParsedText::extractLine( } // Calculate spacing (account for indent reducing effective page width on first line) - const int effectivePageWidth = pageWidth - firstLineIndent; + const int effectivePageWidth = pageWidth - lineIndent; // A line is only truly last when it consumes all paragraph words. // During single-line retry we may temporarily pass a truncated break vector, // so relying only on breakIndex would incorrectly disable justification. @@ -1014,7 +998,7 @@ ParsedText::LineProcessResult ParsedText::extractLine( // Calculate initial x position (first line starts at indent for left/justified text; // may be negative for hanging indents, e.g. margin-left:3em; text-indent:-1em). - auto xpos = static_cast(firstLineIndent); + auto xpos = static_cast(lineIndent); if (blockStyle.alignment == CssTextAlign::Right) { xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps; } else if (blockStyle.alignment == CssTextAlign::Center) { diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 7b19db4c..2112beec 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -32,12 +32,14 @@ class ParsedText { void applyParagraphIndent(); void applyBionicReadingTransform(); std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, - std::vector& wordWidths, std::vector& continuesVec); + std::vector& wordWidths, std::vector& continuesVec, + int firstLineIndent); std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector& wordWidths, std::vector& continuesVec, std::vector& lineEndsWithHyphenatedWord, std::vector& splitPrefixWordIndexes, - std::vector& splitInsertedHyphen); + std::vector& splitInsertedHyphen, + int firstLineIndent); // Recompute hyphenated breaks for a suffix that starts at startIndex. // Used after a single-line retry so later lines keep normal hyphenation. std::vector computeHyphenatedLineBreaksFromIndex(const GfxRenderer& renderer, int fontId, int pageWidth, @@ -50,7 +52,7 @@ class ParsedText { // Used only for the page-boundary retry line. size_t computeSingleLineBreakNoHyphen(const GfxRenderer& renderer, int fontId, int pageWidth, const std::vector& wordWidths, const std::vector& continuesVec, - size_t lineStartIndex) const; + size_t lineStartIndex, int firstLineIndent) const; bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, std::vector& wordWidths, bool allowFallbackBreaks, bool* outInsertedHyphen = nullptr); @@ -58,7 +60,8 @@ class ParsedText { size_t breakIndex, int pageWidth, const std::vector& wordWidths, const std::vector& continuesVec, const std::vector& lineBreakIndices, const std::function, bool, bool)>& processLine, - const GfxRenderer& renderer, int fontId, bool lineEndsWithHyphenatedWord, bool suppressHyphenationRetry); + const GfxRenderer& renderer, int fontId, bool lineEndsWithHyphenatedWord, bool suppressHyphenationRetry, + int firstLineIndent); std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); public: From 0a5fee283968ad3a02fb80fa4b439966fbd2bf43 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 16:55:37 +1200 Subject: [PATCH 13/21] Address review comment: fix bionic reading on continuation --- lib/Epub/Epub/ParsedText.cpp | 67 ++++++++++++++++++++++-------------- lib/Epub/Epub/ParsedText.h | 3 +- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index d265f9d6..75553560 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -215,13 +215,16 @@ void ParsedText::layoutAndExtractLines( } // Apply fixed transforms before any per-line layout work. - // Skip on continuation flushes: the words are mid-paragraph and have - // already been transformed by the initial layoutAndExtractLines call. + // Paragraph indent only applies to the first layout pass; skip on continuations. if (!isContinuation_) { applyParagraphIndent(); - if (bionicReadingEnabled) { - applyBionicReadingTransform(); - } + } + // Bionic transform is incremental: applyBionicReadingTransform() is a no-op + // for already-transformed words (bionicTransformedUpTo_ == words.size()) and + // only processes raw words appended since the last flush, so it is always safe + // to call regardless of isContinuation_. + if (bionicReadingEnabled) { + applyBionicReadingTransform(); } // Ensure SD card font glyph metrics are loaded before measuring word widths. @@ -382,6 +385,9 @@ void ParsedText::layoutAndExtractLines( wordStyles.shrink_to_fit(); wordContinues.shrink_to_fit(); isContinuation_ = !includeLastLine; + // All remaining words were already transformed before the flush; reset the + // watermark so that words appended by addWord() are processed next time. + bionicTransformedUpTo_ = words.size(); } } @@ -585,18 +591,23 @@ void ParsedText::applyParagraphIndent() { } void ParsedText::applyBionicReadingTransform() { - if (words.empty()) { + // Only transform words that haven't been processed yet. On a fresh block + // bionicTransformedUpTo_ == 0 so all words are processed. After an + // intermediate flush, only the new raw words appended since the last flush + // (indices bionicTransformedUpTo_..words.size()-1) need transformation. + if (words.empty() || bionicTransformedUpTo_ >= words.size()) { return; } - std::vector transformedWords; - std::vector transformedStyles; - std::vector transformedContinues; - transformedWords.reserve(words.size() * 2); - transformedStyles.reserve(wordStyles.size() * 2); - transformedContinues.reserve(wordContinues.size() * 2); + const size_t suffixStart = bionicTransformedUpTo_; + std::vector transformedSuffix; + std::vector transformedSuffixStyles; + std::vector transformedSuffixContinues; + transformedSuffix.reserve((words.size() - suffixStart) * 2); + transformedSuffixStyles.reserve(transformedSuffix.capacity()); + transformedSuffixContinues.reserve(transformedSuffix.capacity()); - for (size_t i = 0; i < words.size(); ++i) { + for (size_t i = suffixStart; i < words.size(); ++i) { std::string source = std::move(words[i]); const auto originalStyle = wordStyles[i]; const bool originalAttachToPrevious = wordContinues[i]; @@ -638,29 +649,35 @@ void ParsedText::applyBionicReadingTransform() { std::string suffix(reinterpret_cast(prefixEnd), token.size() - prefixByteCount); token.resize(prefixByteCount); const auto boldStyle = static_cast(originalStyle | EpdFontFamily::BOLD); - transformedWords.push_back(std::move(token)); - transformedStyles.push_back(boldStyle); - transformedContinues.push_back(attachToPrevious); + transformedSuffix.push_back(std::move(token)); + transformedSuffixStyles.push_back(boldStyle); + transformedSuffixContinues.push_back(attachToPrevious); - transformedWords.push_back(std::move(suffix)); - transformedStyles.push_back(originalStyle); - transformedContinues.push_back(true); + transformedSuffix.push_back(std::move(suffix)); + transformedSuffixStyles.push_back(originalStyle); + transformedSuffixContinues.push_back(true); attachToPrevious = true; continue; } } } - transformedWords.push_back(std::move(token)); - transformedStyles.push_back(originalStyle); - transformedContinues.push_back(attachToPrevious); + transformedSuffix.push_back(std::move(token)); + transformedSuffixStyles.push_back(originalStyle); + transformedSuffixContinues.push_back(attachToPrevious); attachToPrevious = true; } } - words = std::move(transformedWords); - wordStyles = std::move(transformedStyles); - wordContinues = std::move(transformedContinues); + // Replace the (now move-emptied) suffix with the transformed version. + words.resize(suffixStart); + wordStyles.resize(suffixStart); + wordContinues.resize(suffixStart); + words.insert(words.end(), std::make_move_iterator(transformedSuffix.begin()), + std::make_move_iterator(transformedSuffix.end())); + wordStyles.insert(wordStyles.end(), transformedSuffixStyles.begin(), transformedSuffixStyles.end()); + wordContinues.insert(wordContinues.end(), transformedSuffixContinues.begin(), transformedSuffixContinues.end()); + bionicTransformedUpTo_ = words.size(); } // Builds break indices while opportunistically splitting the word that would overflow the current line. diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 2112beec..486054ec 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -27,7 +27,8 @@ class ParsedText { bool extraParagraphSpacing; bool hyphenationEnabled; bool bionicReadingEnabled; - bool isContinuation_ = false; ///< true after an intermediate flush; suppresses re-applying indent/transforms + bool isContinuation_ = false; ///< true after an intermediate flush; suppresses re-applying paragraph indent + size_t bionicTransformedUpTo_ = 0; ///< words[0..bionicTransformedUpTo_) have already been bionic-transformed void applyParagraphIndent(); void applyBionicReadingTransform(); From 3f4db13c29f5e320540d0d53c4c1fe8063d45df5 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 17:05:19 +1200 Subject: [PATCH 14/21] Fix formatting --- lib/Epub/Epub/ParsedText.cpp | 21 ++++++++------------- lib/Epub/Epub/ParsedText.h | 5 ++--- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 75553560..c1abcaab 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -325,9 +325,8 @@ void ParsedText::layoutAndExtractLines( // Keep previous lines fixed; recompute only this specific line without hyphenation. // Suppression is intentionally line-local. - const size_t retryBreak = - computeSingleLineBreakNoHyphen(renderer, fontId, pageWidth, wordWidths, wordContinues, lineStart, - firstLineIndent); + const size_t retryBreak = computeSingleLineBreakNoHyphen(renderer, fontId, pageWidth, wordWidths, wordContinues, + lineStart, firstLineIndent); lineBreakIndices.resize(i + 1); lineEndsWithHyphenatedWord.resize(i + 1); @@ -529,8 +528,8 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c size_t ParsedText::computeSingleLineBreakNoHyphen(const GfxRenderer& renderer, const int fontId, const int pageWidth, const std::vector& wordWidths, - const std::vector& continuesVec, - const size_t lineStartIndex, const int firstLineIndent) const { + const std::vector& continuesVec, const size_t lineStartIndex, + const int firstLineIndent) const { // One-line non-hyphenating breaker used by the page-boundary retry path. if (lineStartIndex >= wordWidths.size()) { return lineStartIndex; @@ -681,14 +680,10 @@ void ParsedText::applyBionicReadingTransform() { } // Builds break indices while opportunistically splitting the word that would overflow the current line. -std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, - const int pageWidth, std::vector& wordWidths, - std::vector& continuesVec, - std::vector& lineEndsWithHyphenatedWord, - std::vector& splitPrefixWordIndexes, - std::vector& splitInsertedHyphen, - const int firstLineIndent) { - +std::vector ParsedText::computeHyphenatedLineBreaks( + const GfxRenderer& renderer, const int fontId, const int pageWidth, std::vector& wordWidths, + std::vector& continuesVec, std::vector& lineEndsWithHyphenatedWord, + std::vector& splitPrefixWordIndexes, std::vector& splitInsertedHyphen, const int firstLineIndent) { // Pre-compute inter-word gaps to avoid repeated codepoint scanning and renderer // calls in the inner loop. When hyphenateWordAtIndex inserts a new word, we insert // a placeholder gap (0) at that position to keep the vector in sync; the remainder diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 486054ec..f60388bb 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -27,7 +27,7 @@ class ParsedText { bool extraParagraphSpacing; bool hyphenationEnabled; bool bionicReadingEnabled; - bool isContinuation_ = false; ///< true after an intermediate flush; suppresses re-applying paragraph indent + bool isContinuation_ = false; ///< true after an intermediate flush; suppresses re-applying paragraph indent size_t bionicTransformedUpTo_ = 0; ///< words[0..bionicTransformedUpTo_) have already been bionic-transformed void applyParagraphIndent(); @@ -39,8 +39,7 @@ class ParsedText { std::vector& wordWidths, std::vector& continuesVec, std::vector& lineEndsWithHyphenatedWord, std::vector& splitPrefixWordIndexes, - std::vector& splitInsertedHyphen, - int firstLineIndent); + std::vector& splitInsertedHyphen, int firstLineIndent); // Recompute hyphenated breaks for a suffix that starts at startIndex. // Used after a single-line retry so later lines keep normal hyphenation. std::vector computeHyphenatedLineBreaksFromIndex(const GfxRenderer& renderer, int fontId, int pageWidth, From 13a76597d4ace6e1f10511e15e4c59e760117400 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 19:38:38 +1200 Subject: [PATCH 15/21] Address review comment: check block size as soon as each word is added. --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 231f30fa..cd83b5c5 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -189,6 +189,20 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() { currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues); partWordBufferIndex = 0; nextWordContinues = false; + + if (currentTextBlock->size() > 96) { + LOG_DBG("EHP", "Text block too long, splitting into multiple pages"); + const int horizontalInset = currentTextBlock->getBlockStyle().totalHorizontalInset(); + const uint16_t effectiveWidth = + (horizontalInset < viewportWidth) ? static_cast(viewportWidth - horizontalInset) : viewportWidth; + currentTextBlock->layoutAndExtractLines( + renderer, fontId, effectiveWidth, + [this](const std::shared_ptr& textBlock, const bool lineEndsWithHyphenatedWord, + const bool suppressHyphenationRetry) { + return addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry); + }, + false); + } } // Emit the current page, keeping paragraphLutPerPage and completedPageCount in lockstep. @@ -1206,26 +1220,6 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char self->partWordBuffer[self->partWordBufferIndex++] = s[i]; } - - // Flush when words approach the doubling boundary that would require a - // large contiguous realloc. 96 fires before capacity reaches 128 - // (the next doubling after 64), keeping the realloc below 1.5KB and - // releasing excess capacity via shrink_to_fit in layoutAndExtractLines. - // The original 750-word threshold was too late for low-heap devices. - if (self->currentTextBlock->size() > 96) { - LOG_DBG("EHP", "Text block too long, splitting into multiple pages"); - const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); - const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth) - ? static_cast(self->viewportWidth - horizontalInset) - : self->viewportWidth; - self->currentTextBlock->layoutAndExtractLines( - self->renderer, self->fontId, effectiveWidth, - [self](const std::shared_ptr& textBlock, const bool lineEndsWithHyphenatedWord, - const bool suppressHyphenationRetry) { - return self->addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry); - }, - false); - } } void XMLCALL ChapterHtmlSlimParser::defaultHandlerExpand(void* userData, const XML_Char* s, const int len) { From 9def61aed9a0e1d1314bcd5dcf328f68d07b8932 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 19:51:14 +1200 Subject: [PATCH 16/21] Address review comment: Don't leave dangling pointers when loading ligature/kern darta fails --- lib/EpdFont/SdCardFont.cpp | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index a07660b4..23a90233 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -72,6 +72,11 @@ void SdCardFont::freeStyleKernLigatureData(PerStyle& s) { delete[] s.ligaturePairs; s.ligaturePairs = nullptr; s.ligLoaded = false; + // Clear dangling pointers in EpdFontData structs + s.stubData.ligaturePairs = nullptr; + s.stubData.ligaturePairCount = 0; + s.miniData.ligaturePairs = nullptr; + s.miniData.ligaturePairCount = 0; } void SdCardFont::freeStyleMiniKern(PerStyle& s) { @@ -157,56 +162,61 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s, bool ligatureOnly) { // Load only the small class-lookup tables (~3KB each). The full matrix // (~36KB contiguous for Literata) is built per-page from SD in // buildMiniKernMatrix(). - s.kernLeftClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernLeftEntryCount]; - s.kernRightClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernRightEntryCount]; + EpdKernClassEntry* newLeft = new (std::nothrow) EpdKernClassEntry[s.header.kernLeftEntryCount]; + EpdKernClassEntry* newRight = new (std::nothrow) EpdKernClassEntry[s.header.kernRightEntryCount]; - if (!s.kernLeftClasses || !s.kernRightClasses) { + if (!newLeft || !newRight) { + delete[] newLeft; + delete[] newRight; LOG_ERR("SDCF", "Failed to allocate kern classes (%u+%u bytes)", s.header.kernLeftEntryCount * 3u, s.header.kernRightEntryCount * 3u); - freeStyleKernLigatureData(s); file.close(); return false; } if (!file.seekSet(s.kernLeftFileOffset)) { + delete[] newLeft; + delete[] newRight; LOG_ERR("SDCF", "Failed to seek to kern data"); - freeStyleKernLigatureData(s); file.close(); return false; } size_t leftSz = s.header.kernLeftEntryCount * sizeof(EpdKernClassEntry); size_t rightSz = s.header.kernRightEntryCount * sizeof(EpdKernClassEntry); - if (file.read(reinterpret_cast(s.kernLeftClasses), leftSz) != static_cast(leftSz) || - file.read(reinterpret_cast(s.kernRightClasses), rightSz) != static_cast(rightSz)) { + if (file.read(reinterpret_cast(newLeft), leftSz) != static_cast(leftSz) || + file.read(reinterpret_cast(newRight), rightSz) != static_cast(rightSz)) { + delete[] newLeft; + delete[] newRight; LOG_ERR("SDCF", "Failed to read kern classes"); - freeStyleKernLigatureData(s); file.close(); return false; } + s.kernLeftClasses = newLeft; + s.kernRightClasses = newRight; s.kernClassesLoaded = true; } if (wantLig && !s.ligLoaded) { - s.ligaturePairs = new (std::nothrow) EpdLigaturePair[s.header.ligaturePairCount]; - if (!s.ligaturePairs) { + EpdLigaturePair* newLig = new (std::nothrow) EpdLigaturePair[s.header.ligaturePairCount]; + if (!newLig) { LOG_ERR("SDCF", "Failed to allocate ligature pairs"); - freeStyleKernLigatureData(s); file.close(); return false; } if (!file.seekSet(s.ligatureFileOffset)) { + delete[] newLig; LOG_ERR("SDCF", "Failed to seek to ligature data"); - freeStyleKernLigatureData(s); file.close(); return false; } size_t sz = s.header.ligaturePairCount * sizeof(EpdLigaturePair); - if (file.read(reinterpret_cast(s.ligaturePairs), sz) != static_cast(sz)) { + if (file.read(reinterpret_cast(newLig), sz) != static_cast(sz)) { + delete[] newLig; LOG_ERR("SDCF", "Failed to read ligature pairs"); - freeStyleKernLigatureData(s); file.close(); return false; } + s.ligaturePairs = newLig; s.ligLoaded = true; // Make ligatures visible to the stub (used when no mini data built yet). From acfb9e6cdc0546a95df52afb779c41c12c632976 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 20:03:35 +1200 Subject: [PATCH 17/21] Address Review Comment: clear dangling pointers when miniData freed --- lib/EpdFont/SdCardFont.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 23a90233..62bd8469 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -52,6 +52,21 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) { s.miniIntervalCount = 0; s.miniGlyphCount = 0; s.miniMode = PerStyle::MiniMode::NONE; + // Clear dangling pointers in miniData and stubData (kern data points to freed mini arrays) + s.miniData.kernLeftClasses = nullptr; + s.miniData.kernRightClasses = nullptr; + s.miniData.kernMatrix = nullptr; + s.miniData.kernLeftEntryCount = 0; + s.miniData.kernRightEntryCount = 0; + s.miniData.kernLeftClassCount = 0; + s.miniData.kernRightClassCount = 0; + s.stubData.kernLeftClasses = nullptr; + s.stubData.kernRightClasses = nullptr; + s.stubData.kernMatrix = nullptr; + s.stubData.kernLeftEntryCount = 0; + s.stubData.kernRightEntryCount = 0; + s.stubData.kernLeftClassCount = 0; + s.stubData.kernRightClassCount = 0; // NOTE: reportedMissCount is intentionally NOT reset here. The merge path // calls freeStyleMiniData() to swap mini buffers, and resetting the miss // tracker every paragraph would re-spam the log for the same 4 missing cps. From f07724dab431552b12b7d45e4417d4af95c7f233 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 20:09:52 +1200 Subject: [PATCH 18/21] Address review comment: Ensure ligature metadata is wired if requested but not yet wired --- lib/EpdFont/SdCardFont.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 62bd8469..143e6c10 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -767,6 +767,18 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 // For metadata-only calls, METADATA or FULL cache both satisfy layout queries. // For full (bitmap) calls, only FULL satisfies — METADATA lacks bitmap data. if (metadataOnly || s.miniMode == PerStyle::MiniMode::FULL) { + // Ensure ligature metadata is wired if requested but not yet wired. + if (loadKernLigatureData && !s.ligLoaded) { + loadStyleKernLigatureData(s, /*ligatureOnly=*/true); + } + if (loadKernLigatureData && s.ligLoaded && s.miniData.ligaturePairs == nullptr) { + if (s.miniMode == PerStyle::MiniMode::FULL) { + applyKernLigaturePointers(s, s.miniData); + } else { + s.miniData.ligaturePairs = s.ligaturePairs; + s.miniData.ligaturePairCount = s.header.ligaturePairCount; + } + } // Already wired into miniData; nothing else to do. return 0; } From 383523696199cad06036d21d177d6bbf99e342f6 Mon Sep 17 00:00:00 2001 From: spfenwick Date: Sat, 2 May 2026 20:23:29 +1200 Subject: [PATCH 19/21] Address nitpick review comments --- lib/Epub/Epub/ParsedText.cpp | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index c1abcaab..7f584f32 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -383,11 +383,11 @@ void ParsedText::layoutAndExtractLines( words.shrink_to_fit(); wordStyles.shrink_to_fit(); wordContinues.shrink_to_fit(); - isContinuation_ = !includeLastLine; // All remaining words were already transformed before the flush; reset the // watermark so that words appended by addWord() are processed next time. bionicTransformedUpTo_ = words.size(); } + isContinuation_ = !includeLastLine; } std::vector ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) { @@ -610,7 +610,6 @@ void ParsedText::applyBionicReadingTransform() { std::string source = std::move(words[i]); const auto originalStyle = wordStyles[i]; const bool originalAttachToPrevious = wordContinues[i]; - const char* raw = source.c_str(); const auto spans = tokenizeBionicWord(source); if (spans.empty()) { @@ -621,12 +620,7 @@ void ParsedText::applyBionicReadingTransform() { for (size_t spanIndex = 0; spanIndex < spans.size(); ++spanIndex) { const TokenSpan span = spans[spanIndex]; const size_t spanLength = span.end - span.start; - std::string token; - if (spans.size() == 1 && spanIndex == 0) { - token = std::move(source); - } else { - token.assign(raw + span.start, spanLength); - } + std::string token = source.substr(span.start, spanLength); if (span.isWord) { const unsigned char* ptr = reinterpret_cast(token.c_str()); From bd3ceb6e992dcb6dc2c2bc92d24a19dd9c9d64ed Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 2 May 2026 10:44:13 +0200 Subject: [PATCH 20/21] Add itsthisjustins ota fix for x3 --- platformio.ini | 2 +- src/platform/skip_efuse_blk_check.c | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/platform/skip_efuse_blk_check.c diff --git a/platformio.ini b/platformio.ini index 1d814e18..6777dcc4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ build_flags = # Default is (320*4+1)*2=2562, we need more for larger images -DPNG_MAX_BUFFERED_PIXELS=16416 -Wno-bidi-chars - -Wl,--wrap=panic_print_backtrace,--wrap=panic_abort + -Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity -fno-exceptions build_unflags = diff --git a/src/platform/skip_efuse_blk_check.c b/src/platform/skip_efuse_blk_check.c new file mode 100644 index 00000000..d3e4ea4f --- /dev/null +++ b/src/platform/skip_efuse_blk_check.c @@ -0,0 +1,11 @@ +// Override the prebuilt libbootloader_support.a implementation. +// The X3's validation code misreads the new image's esp_app_desc_t through a +// misaligned bootloader_mmap pointer, producing garbage eFuse block revision +// values that fail the check. Safe to skip: the eFuse block revision gate is +// a manufacturing concern, not a runtime safety issue. +#include +esp_err_t __wrap_bootloader_common_check_efuse_blk_validity(uint32_t min_rev_full, uint32_t max_rev_full) { + (void)min_rev_full; + (void)max_rev_full; + return ESP_OK; +} \ No newline at end of file From 62a3ab0d6619ba0eb40fb224b7238752e45edaa4 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 2 May 2026 23:11:22 +0200 Subject: [PATCH 21/21] Add SD card font families and styles Updated SD card font families configuration with detailed descriptions and styles for various fonts. --- assets/sd-fonts/sd-fonts.yaml | 294 +++++++++++++++++++++++++++++++++- 1 file changed, 293 insertions(+), 1 deletion(-) diff --git a/assets/sd-fonts/sd-fonts.yaml b/assets/sd-fonts/sd-fonts.yaml index fc0626f9..36b39c31 120000 --- a/assets/sd-fonts/sd-fonts.yaml +++ b/assets/sd-fonts/sd-fonts.yaml @@ -1 +1,293 @@ -../../lib/EpdFont/scripts/sd-fonts.yaml +# SD Card Font Families for CrossPoint++ Reader +# +# This file is the single source of truth for which fonts are generated, +# how they're sourced, and how they're described in the download manifest. +# +# Adding a new font family = adding a block here. No code changes needed. +# +# Fields: +# name: Output family name (used in filenames and on-device UI) +# description: Human-readable description (shown in download UI and manifest) +# intervals: Comma-separated Unicode interval presets for fontconvert_sdcard.py +# sizes: Point sizes to generate +# force_autohint: (optional) Force FreeType auto-hinter instead of native hinting +# styles: Map of style name -> font source +# path: relative to lib/EpdFont (for committed fonts) +# url: download URL (for fonts not in the repo) +# +# Variable fonts: +# Some fonts (Bitter, Inter, Alegreya) are distributed as variable fonts. +# freetype-py can't set variable font axis values, so the build script +# uses fonttools.instancer to extract static instances automatically. +# +# To use a variable font, add a 'variable' key to the style spec with +# axis values to pin: +# +# styles: +# regular: {url: "https://...Font[wght].ttf", variable: {wght: 400}} +# bold: {url: "https://...Font[wght].ttf", variable: {wght: 700}} +# +# Extracted static fonts are cached in instanced_fonts/ (gitignored). +# Requires fonttools: pip install -r requirements.txt + +families: + # ── Serif ────────────────────────────────────────────────────────────── + + - name: Literata + description: "Screen-optimized serif (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-BoldItalic.ttf"} + + - name: SourceSerif4 + description: "Adobe transitional serif (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-It.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-BoldIt.ttf"} + + - name: NotoSerifExtended + description: "Serif (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-BoldItalic.ttf"} + + - name: Merriweather + description: "Warm serif for long-form reading (Latin, Cyrillic)" + intervals: latin-ext,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-BoldItalic.ttf"} + + - name: Lora + description: "Calligraphic serif for literary reading (Latin, Cyrillic)" + intervals: latin-ext,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-BoldItalic.ttf"} + + - name: Gelasio + description: "Readable old-style serif (Latin, Cyrillic)" + intervals: latin-ext,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio%5Bwght%5D.ttf", variable: {wght: 400}} + bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio%5Bwght%5D.ttf", variable: {wght: 700}} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio-Italic%5Bwght%5D.ttf", variable: {wght: 400}} + bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gelasio/Gelasio-Italic%5Bwght%5D.ttf", variable: {wght: 700}} + + - name: GentiumBookPlus + description: "Scholarly serif with wide Unicode coverage (Latin, Greek, Cyrillic, IPA)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-BoldItalic.ttf"} + + - name: IBMPlexSerif + description: "Professional serif (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-BoldItalic.ttf"} + + - name: Bitter + description: "Slab serif designed for e-ink (Latin, Cyrillic)" + intervals: latin-ext,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 400}} + bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 700}} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 400}} + bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}} + + # ── Sans-serif ───────────────────────────────────────────────────────── + + - name: NotoSansExtended + description: "Sans-serif (Latin, Greek, Cyrillic, Georgian, Armenian, Ethiopic)" + intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {path: "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"} + bold: {path: "builtinFonts/source/NotoSans/NotoSans-Bold.ttf"} + italic: {path: "builtinFonts/source/NotoSans/NotoSans-Italic.ttf"} + bolditalic: {path: "builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf"} + + - name: Inter + description: "Modern sans-serif (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}} + bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}} + bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}} + + - name: SourceSans3 + description: "Adobe humanist sans-serif (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-It.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-BoldIt.ttf"} + + - name: IBMPlexSans + description: "IBM corporate sans-serif (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-BoldItalic.ttf"} + + - name: Alegreya + description: "Calligraphic serif/display (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 400}} + bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 700}} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 400}} + bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 700}} + + # ── Monospace ────────────────────────────────────────────────────────── + + - name: IBMPlexMono + description: "Monospace for code and technical reading (Latin, Greek, Cyrillic)" + intervals: latin-ext,greek,cyrillic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-BoldItalic.ttf"} + + - name: SourceCodePro + description: "Adobe monospace with excellent hinting (Latin)" + intervals: latin-ext + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf"} + + # ── Accessibility ────────────────────────────────────────────────────── + + - name: AtkinsonHyperlegibleNext + description: "Accessibility font for low vision (Latin)" + intervals: latin-ext + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-BoldItalic.ttf"} + + - name: LexicaUltralegible + description: "Accessibility font for low vision / dyslexia (Latin)" + intervals: latin-ext + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf"} + bold: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Bold.ttf"} + italic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Italic.ttf"} + bolditalic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-BoldItalic.ttf"} + + # OpenDyslexic was previously baked into the firmware. It is now distributed + # as an SD-card font so users who don't need it don't pay the ~3MB flash cost. + # Sizes 8/10 retained for backward compatibility with the previous built-in mapping. + - name: OpenDyslexic + description: "Dyslexia-friendly font (Latin, Latin-Extended)" + intervals: latin-ext + sizes: [8, 10, 12, 14, 16] + styles: + regular: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf"} + bold: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf"} + italic: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf"} + bolditalic: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf"} + + # ── CJK & Hangul ────────────────────────────────────────────────────── + + - name: NotoSansCJK + description: "Sans-serif (Chinese, Japanese, Korean)" + intervals: ascii,latin1,punctuation,cjk + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf"} + + - name: NotoSerifCJK + description: "Serif (Chinese, Japanese, Korean)" + intervals: ascii,latin1,punctuation,cjk + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://github.com/notofonts/noto-cjk/raw/main/Serif/OTF/SimplifiedChinese/NotoSerifCJKsc-Regular.otf"} + + - name: NotoSansHangul + description: "Sans-serif (Korean Hangul)" + intervals: ascii,latin1,punctuation,hangul + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf"} + + # ── Additional scripts (no complex shaping needed) ───────────────────── + + - name: NotoSansArmenian + description: "Armenian script" + intervals: armenian + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosansarmenian/NotoSansArmenian%5Bwdth%2Cwght%5D.ttf", variable: {wght: 400, wdth: 100}} + + - name: NotoSansGeorgian + description: "Georgian script" + intervals: georgian + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosansgeorgian/NotoSansGeorgian%5Bwdth%2Cwght%5D.ttf", variable: {wght: 400, wdth: 100}} + + - name: NotoSansEthiopic + description: "Ethiopic/Ge'ez script" + intervals: ethiopic + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosansethiopic/NotoSansEthiopic%5Bwdth%2Cwght%5D.ttf", variable: {wght: 400, wdth: 100}} + + - name: NotoSansCherokee + description: "Cherokee syllabary" + intervals: cherokee + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosanscherokee/NotoSansCherokee%5Bwght%5D.ttf", variable: {wght: 400}} + + - name: NotoSansTifinagh + description: "Tifinagh script" + intervals: tifinagh + sizes: [10, 12, 14, 16, 18] + styles: + regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/notosanstifinagh/NotoSansTifinagh-Regular.ttf"}