From 79b4d63ee271ea27b15d0f0f091d04bfd34e2d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Sypia=C5=84ski?= Date: Fri, 3 Jul 2026 17:58:24 +0200 Subject: [PATCH 01/68] fix: settings persist on font clear, reserve() before push_back, minor (#2519) --- lib/OpdsParser/OpdsParser.cpp | 9 +++++---- lib/Txt/Txt.cpp | 2 +- src/JsonSettingsIO.cpp | 6 ++---- src/SdCardFontSystem.cpp | 5 +++++ src/WifiCredentialStore.cpp | 3 +++ src/activities/home/FileBrowserActivity.cpp | 2 +- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index 26cdaed886..d00d02eddc 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -10,7 +10,11 @@ OpdsParser::OpdsParser() { if (!parser) { errorOccured = true; LOG_DBG("OPDS", "Couldn't allocate memory for parser"); + return; } + XML_SetUserData(parser, this); + XML_SetElementHandler(parser, startElement, endElement); + XML_SetCharacterDataHandler(parser, characterData); } OpdsParser::~OpdsParser() { destroyXmlParser(parser); } @@ -20,10 +24,6 @@ size_t OpdsParser::write(uint8_t c) { return write(&c, 1); } size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { if (errorOccured) return length; - XML_SetUserData(parser, this); - XML_SetElementHandler(parser, startElement, endElement); - XML_SetCharacterDataHandler(parser, characterData); - const char* currentPos = reinterpret_cast(xmlData); size_t remaining = length; constexpr size_t chunkSize = 1024; @@ -54,6 +54,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { } void OpdsParser::flush() { + if (errorOccured || !parser) return; if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) { errorOccured = true; destroyXmlParser(parser); diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 581c8c624b..ef9ee1754d 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -42,7 +42,7 @@ std::string Txt::getTitle() const { // Remove .txt extension if (FsHelpers::hasTxtExtension(filename)) { - filename = filename.substr(0, filename.length() - 4); + filename.resize(filename.length() - 4); } return filename; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 6f0a51d280..92139b69a0 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -153,10 +154,6 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) // Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders. doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN"; - // Language -- managed by LanguageSelectActivity, not in SettingsList. - // Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders. - doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN"; - String json; serializeJson(doc, json); return Storage.writeFile(path, json); @@ -345,6 +342,7 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) store.recentBooks.clear(); JsonArray arr = doc["books"].as(); + store.recentBooks.reserve(std::min(arr.size(), (size_t)10)); for (JsonObject obj : arr) { if (store.getCount() >= 10) break; RecentBook book; diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index 79aa122649..d0b311bb48 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -34,10 +34,12 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) { } else { LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName); SETTINGS.sdFontFamilyName[0] = '\0'; + SETTINGS.saveToFile(); } } else { LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName); SETTINGS.sdFontFamilyName[0] = '\0'; + SETTINGS.saveToFile(); } } @@ -76,6 +78,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily); manager_.unloadAll(renderer); SETTINGS.sdFontFamilyName[0] = '\0'; + SETTINGS.saveToFile(); return; } const auto* selected = family->findClosestReaderSize(sizeEnum); @@ -96,10 +99,12 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { } else { LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily); SETTINGS.sdFontFamilyName[0] = '\0'; + SETTINGS.saveToFile(); } } else { LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily); SETTINGS.sdFontFamilyName[0] = '\0'; + SETTINGS.saveToFile(); } } diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index 4776023733..6827ad5cfa 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -6,6 +6,8 @@ #include #include +#include + // Initialize the static instance WifiCredentialStore WifiCredentialStore::instance; @@ -89,6 +91,7 @@ bool WifiCredentialStore::loadFromBinaryFile() { serialization::readPod(file, count); credentials.clear(); + credentials.reserve(std::min(count, MAX_NETWORKS)); for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) { WifiCredential cred; serialization::readString(file, cred.ssid); diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 256a67552d..18577923c0 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -336,7 +336,7 @@ std::string getFileName(std::string filename) { return filename.substr(0, pos); } -std::string getFileExtension(std::string filename) { +std::string getFileExtension(const std::string& filename) { if (filename.back() == '/') { return ""; } From 685d4e88f9745007124a034386f045d00d932856 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sat, 4 Jul 2026 14:21:24 -0400 Subject: [PATCH 02/68] feat: Lazy incremental EPUB section indexing (#2452) Co-authored-by: Uri Tauber Co-authored-by: Julia Nguyen --- lib/Epub/Epub/Section.cpp | 614 +++++++++++++++--- lib/Epub/Epub/Section.h | 112 +++- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 114 ++-- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 33 +- lib/KOReaderSync/ChapterXPathResolver.cpp | 15 +- lib/KOReaderSync/ProgressMapper.cpp | 9 +- src/activities/ActivityResult.h | 4 + src/activities/reader/EpubReaderActivity.cpp | 350 +++++++--- src/activities/reader/EpubReaderActivity.h | 26 +- .../reader/EpubReaderBookmarksActivity.cpp | 14 +- src/activities/reader/ReaderActivity.cpp | 8 + src/activities/reader/ReaderActivity.h | 3 +- src/components/themes/BaseTheme.cpp | 10 +- src/components/themes/BaseTheme.h | 3 +- 14 files changed, 1054 insertions(+), 261 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 12ef05ebd7..2de11f58d4 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "Epub/css/CssParser.h" @@ -12,32 +13,57 @@ namespace { // v28: text decoration bits now include line-through in serialized wordStyles. constexpr uint8_t SECTION_FILE_VERSION = 28; +// Written into the version field while a build is in progress; patched to +// SECTION_FILE_VERSION only when the build is finalized. An abandoned / +// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects +// as unknown and clears -- so an incomplete file is never mistaken for a valid one. +constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0; +// Written when a build is suspended partway (reader exited or device slept mid-build). +// The file carries valid pages 0..pageCount-1, all LUTs, and a trailer with the parse +// watermark (bytesConsumed, totalBytes) appended after the li LUT. loadSectionFile +// accepts it so a resume shows those pages instantly; the reader extends it by +// rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION, +// so finalized files are untouched by this feature; older firmware treats the sentinel +// as an unknown version and rebuilds, which is a safe downgrade. +constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t); - -struct PageLutEntry { - uint32_t fileOffset; - uint16_t paragraphIndex; - uint16_t listItemIndex; -}; } // namespace +// Out-of-line so the unique_ptr in BuildContext can be +// constructed/destroyed where the parser's full definition is visible. +Section::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") {} + +// Suspend any in-progress build so every section.reset() / navigation / sleep path +// persists the pages already laid out as a partial .bin instead of discarding them +// (no-op once a build has completed or never started). +Section::~Section() { suspendBuild(); } + uint32_t Section::onPageComplete(std::unique_ptr page) { if (!file) { - LOG_ERR("SCT", "File not open for writing page %d", pageCount); + LOG_ERR("SCT", "File not open for writing page %d", builtPageCount_); return 0; } const uint32_t position = file.position(); if (!page->serialize(file)) { - LOG_ERR("SCT", "Failed to serialize page %d", pageCount); + LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_); return 0; } - LOG_DBG("SCT", "Page %d processed", pageCount); + LOG_DBG("SCT", "Page %d processed", builtPageCount_); - pageCount++; + builtPageCount_++; + // pageCount is the pages available to read: a rebuild over a partial only raises it + // once it has laid out more pages than the partial already covers. + if (builtPageCount_ > pageCount) { + pageCount = builtPageCount_; + } return position; } @@ -56,7 +82,9 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t), "Header size mismatch"); - serialization::writePod(file, SECTION_FILE_VERSION); + // Written as the incomplete sentinel; finalizeBuild() patches it to + // SECTION_FILE_VERSION as the last step, committing the file. + serialization::writePod(file, SECTION_FILE_INCOMPLETE_VERSION); serialization::writePod(file, fontId); serialization::writePod(file, lineCompression); serialization::writePod(file, extraParagraphSpacing); @@ -83,16 +111,18 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con } // Match parameters + bool filePartial = false; { uint8_t version; serialization::readPod(file, version); - if (version != SECTION_FILE_VERSION) { + if (version != SECTION_FILE_VERSION && version != SECTION_FILE_PARTIAL_VERSION) { // Explicit close() required: member variable persists beyond function scope file.close(); LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version); clearCache(); return false; } + filePartial = (version == SECTION_FILE_PARTIAL_VERSION); int fileFontId; uint16_t fileViewportWidth, fileViewportHeight; @@ -127,14 +157,42 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con } serialization::readPod(file, pageCount); + + if (filePartial) { + // A partial's pageCount is the watermark of a suspended build. Read the watermark + // trailer (appended after the li LUT) so estimatedTotalPages can extrapolate. + uint32_t liLutOffset = 0; + file.seek(HEADER_SIZE - sizeof(uint32_t)); + serialization::readPod(file, liLutOffset); + const uint32_t trailerOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); + const bool trailerValid = + pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size(); + if (!trailerValid) { + file.close(); + LOG_ERR("SCT", "Deserialization failed: malformed partial section"); + clearCache(); + pageCount = 0; + return false; + } + file.seek(trailerOffset); + serialization::readPod(file, partialBytesConsumed_); + serialization::readPod(file, partialTotalBytes_); + partial_ = true; + partialPageCount_ = pageCount; + } + // Explicit close() required: member variable persists beyond function scope file.close(); - LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount); + LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : ""); return true; } // Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem) bool Section::clearCache() const { + const std::string tmpBin = binTmpPath(); + if (Storage.exists(tmpBin.c_str())) { + Storage.remove(tmpBin.c_str()); + } if (!Storage.exists(filePath.c_str())) { LOG_DBG("SCT", "Cache does not exist, no action needed"); return true; @@ -154,8 +212,43 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering, const bool focusReadingEnabled, const std::function& popupFn) { + // One-shot build: start, then lay out the whole section in a single pass. + if (!startBuild(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight, + hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled, popupFn)) { + return false; + } + if (!buildSomeMore(0)) { // 0 = build to completion + return false; + } + return buildComplete_; +} + +bool Section::startBuild(const int fontId, const float lineCompression, const bool extraParagraphSpacing, + const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, + const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering, + const bool focusReadingEnabled, const std::function& popupFn) { + if (build_) { + LOG_ERR("SCT", "startBuild called while a build is already active"); + return false; + } + buildComplete_ = false; + builtPageCount_ = 0; + // Pages from a loaded partial stay readable (from filePath) while this build writes + // to the tmp .bin, so availability never drops below the partial's watermark. + pageCount = partial_ ? partialPageCount_ : 0; + + // Remove a stale tmp .bin from a crash-interrupted build; this build recreates it. + { + const std::string staleTmp = binTmpPath(); + if (Storage.exists(staleTmp.c_str())) { + Storage.remove(staleTmp.c_str()); + } + } + const auto localPath = epub->getSpineItem(spineIndex).href; - const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; + const auto htmlDir = epub->getCachePath() + "/html"; + const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html"; + const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html"; // Create cache directory if it doesn't exist { @@ -163,62 +256,101 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c Storage.mkdir(sectionsDir.c_str()); } - // Retry logic for SD card timing issues - bool success = false; - uint32_t fileSize = 0; - for (int attempt = 0; attempt < 3 && !success; attempt++) { - if (attempt > 0) { - LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1); - delay(50); // Brief delay before retry - } + // Reuse the previously unzipped HTML if we already have it. The unzipped HTML is keyed only on the + // book (it lives in the per-book cache dir), not on render settings, so it survives the invalidation + // that wipes the layout (.bin) caches when font/margin/orientation change -- rebuilds then skip zip + // inflation entirely. It's promoted by an atomic rename as soon as the inflate succeeds (below), so + // even a window-only giant spine -- whose .bin never finalizes -- still caches its HTML, letting a + // reopen skip the multi-second inflate. If htmlPath exists it is known-complete. + const bool reusedHtml = Storage.exists(htmlPath.c_str()); + bool htmlCached = reusedHtml; + if (reusedHtml) { + LOG_DBG("SCT", "Reusing cached HTML %s", htmlPath.c_str()); + } else { + Storage.mkdir(htmlDir.c_str()); + + // Retry logic for SD card timing issues + bool streamed = false; + uint32_t fileSize = 0; + for (int attempt = 0; attempt < 3 && !streamed; attempt++) { + if (attempt > 0) { + LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1); + delay(50); // Brief delay before retry + } + + // Remove any incomplete file from previous attempt before retrying + if (Storage.exists(tmpHtmlPath.c_str())) { + Storage.remove(tmpHtmlPath.c_str()); + } - // Remove any incomplete file from previous attempt before retrying - if (Storage.exists(tmpHtmlPath.c_str())) { - Storage.remove(tmpHtmlPath.c_str()); + HalFile tmpHtml; + if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { + continue; + } + // Larger chunks mean far fewer SD writes inflating the HTML; a 1KB chunk turned a 584KB + // single-spine novel into ~570 tiny writes (multi-second). 8KB keeps the transient buffers + // small while cutting the write count 8x. + streamed = epub->readItemContentsToStream(localPath, tmpHtml, 8192); + fileSize = tmpHtml.size(); + // Explicitly close() file before calling Storage.remove() + tmpHtml.close(); + + // If streaming failed, remove the incomplete file immediately + if (!streamed && Storage.exists(tmpHtmlPath.c_str())) { + Storage.remove(tmpHtmlPath.c_str()); + LOG_DBG("SCT", "Removed incomplete temp file after failed attempt"); + } } - HalFile tmpHtml; - if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { - continue; + if (!streamed) { + LOG_ERR("SCT", "Failed to stream item contents to temp file after retries"); + return false; } - success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); - fileSize = tmpHtml.size(); - // Explicitly close() file before calling Storage.remove() - tmpHtml.close(); - - // If streaming failed, remove the incomplete file immediately - if (!success && Storage.exists(tmpHtmlPath.c_str())) { - Storage.remove(tmpHtmlPath.c_str()); - LOG_DBG("SCT", "Removed incomplete temp file after failed attempt"); + + LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize); + + // Promote to the persistent HTML cache immediately -- the inflate is complete and the bytes are + // valid regardless of whether the layout build finishes, so reopening (even a window-only spine + // that never finalizes its .bin) skips re-inflation. If the rename fails we just parse the temp. + if (Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) { + htmlCached = true; + } else { + LOG_DBG("SCT", "Failed to promote HTML cache; parsing from temp"); } } - if (!success) { - LOG_ERR("SCT", "Failed to stream item contents to temp file after retries"); + if (!Storage.openFileForWrite("SCT", binTmpPath(), file)) { + if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str()); return false; } + // Header is written with the incomplete-version sentinel; finalizeBuild() commits it. + writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, + viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled); - LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize); - - if (!Storage.openFileForWrite("SCT", filePath, file)) { + auto ctx = makeUniqueNoThrow(); + if (!ctx) { + LOG_ERR("SCT", "OOM: BuildContext"); + file.close(); + Storage.remove(binTmpPath().c_str()); + if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str()); return false; } - writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, - viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled); - std::vector lut = {}; + // htmlCached == "htmlPath is the live cache" (reused, or just promoted). finalizeBuild/abandonBuild + // then leave the cached HTML alone; only an un-promoted temp (rename failed) is theirs to clean up. + ctx->reusedHtml = htmlCached; + ctx->htmlPath = htmlPath; + ctx->tmpHtmlPath = tmpHtmlPath; + ctx->parsePath = htmlCached ? htmlPath : tmpHtmlPath; // 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) + "_"; + const size_t lastSlash = localPath.find_last_of('/'); + ctx->contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : ""; + ctx->imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_"; - CssParser* cssParser = nullptr; if (embeddedStyle) { - cssParser = epub->getCssParser(); - if (cssParser) { - if (!cssParser->loadFromCache()) { - LOG_ERR("SCT", "Failed to load CSS from cache"); - } + ctx->cssParser = epub->getCssParser(); + if (ctx->cssParser && !ctx->cssParser->loadFromCache()) { + LOG_ERR("SCT", "Failed to load CSS from cache"); } } @@ -235,104 +367,367 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c } } - ChapterHtmlSlimParser visitor( - epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, - viewportHeight, hyphenationEnabled, focusReadingEnabled, - [this, &lut](std::unique_ptr page, const uint16_t paragraphIndex, const uint16_t listItemIndex) { - lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex}); + // The parser stores the path/contentBase/imageBasePath by reference, so they must + // live in the BuildContext (which outlives the parser). The page-complete callback + // captures the BuildContext pointer to append to its in-RAM LUT; build_ owns the + // context for the parser's whole lifetime. + BuildContext* ctxPtr = ctx.get(); + ctx->parser = makeUniqueNoThrow( + epub, ctxPtr->parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, + viewportWidth, viewportHeight, hyphenationEnabled, focusReadingEnabled, + [this, ctxPtr](std::unique_ptr page, const uint16_t paragraphIndex, const uint16_t listItemIndex) { + ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex}); }, - embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser); + embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, imageRendering, std::move(tocAnchors), popupFn, + ctxPtr->cssParser); + if (!ctx->parser) { + LOG_ERR("SCT", "OOM: ChapterHtmlSlimParser"); + if (ctx->cssParser) ctx->cssParser->clear(); + file.close(); + Storage.remove(binTmpPath().c_str()); + if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str()); + return false; + } + Hyphenator::setPreferredLanguage(epub->getLanguage()); - success = visitor.parseAndBuildPages(); + build_ = std::move(ctx); - Storage.remove(tmpHtmlPath.c_str()); - if (!success) { - LOG_ERR("SCT", "Failed to parse XML and build pages"); - // Explicitly close() file before calling Storage.remove() - file.close(); - Storage.remove(filePath.c_str()); - if (cssParser) { - cssParser->clear(); - } + if (!build_->parser->beginParse()) { + LOG_ERR("SCT", "Failed to begin parse"); + abandonBuild(); return false; } + build_->totalBytes = build_->parser->parseTotalBytes(); + return true; +} - const uint32_t lutOffset = file.position(); - bool hasFailedLutRecords = false; - // Write LUT - for (const auto& entry : lut) { - if (entry.fileOffset == 0) { - hasFailedLutRecords = true; - break; +bool Section::buildSomeMore(const int maxPages) { + if (!build_ || !build_->parser) { + LOG_ERR("SCT", "buildSomeMore with no active build"); + return false; + } + // Pace on pages laid out by THIS build, not pageCount: during a rebuild over a partial, + // pageCount stays pinned at the partial's watermark until the build passes it, which + // would otherwise turn one "small" chunk into a blocking rebuild of the whole watermark. + const int startCount = builtPageCount_; + for (;;) { + const auto status = build_->parser->parseStep(); + if (status == ChapterHtmlSlimParser::ParseStatus::Error) { + LOG_ERR("SCT", "Parse error during incremental build"); + abandonBuild(); + return false; } - serialization::writePod(file, entry.fileOffset); + if (status == ChapterHtmlSlimParser::ParseStatus::Done) { + return finalizeBuild(); + } + // ParseStatus::More: yield once we've laid out the requested number of pages. + if (maxPages > 0 && (builtPageCount_ - startCount) >= maxPages) { + build_->bytesConsumed = build_->parser->parseBytesConsumed(); + return true; + } + } +} + +bool Section::hasHtmlCache() const { + const std::string htmlPath = epub->getCachePath() + "/html/" + std::to_string(spineIndex) + ".html"; + return Storage.exists(htmlPath.c_str()); +} + +std::optional Section::findAnchorDuringBuild(const std::string& anchor) const { + if (!build_ || !build_->parser) return std::nullopt; + for (const auto& [key, page] : build_->parser->getAnchors()) { + if (key == anchor) return page; } + return std::nullopt; +} - if (hasFailedLutRecords) { - LOG_ERR("SCT", "Failed to write LUT due to invalid page positions"); - // Explicitly close() file before calling Storage.remove() +std::optional Section::findAnchor(const std::string& anchor) const { + if (const auto page = findAnchorDuringBuild(anchor)) { + return page; + } + // Fall back to the on-disk anchor map: a finalized section, or a partial whose map + // covers everything up to its watermark (nullopt past it -- build further and retry). + return getPageForAnchor(anchor); +} + +uint16_t Section::estimatedTotalPages() const { + // Extrapolation from a suspended session's watermark trailer. A static snapshot, so no EMA + // damping is needed. Also the best guess while a rebuild is running but hasn't laid out + // enough pages yet to extrapolate from its own progress. + const auto partialEstimate = [this]() -> uint16_t { + if (!partial_ || partialBytesConsumed_ == 0 || partialTotalBytes_ <= partialBytesConsumed_) { + return pageCount; + } + const uint64_t est = static_cast(partialPageCount_) * partialTotalBytes_ / partialBytesConsumed_; + if (est <= pageCount) return pageCount; + return est > 60000 ? 60000 : static_cast(est); + }; + + if (!build_) { + return partial_ ? partialEstimate() : pageCount; // partial -> extrapolate, finalized -> exact + } + const uint32_t consumed = build_->bytesConsumed; + const uint32_t total = build_->totalBytes; + if (builtPageCount_ == 0 || consumed == 0 || total <= consumed) return partialEstimate(); + + // Raw extrapolation: scale the pages built so far by the fraction of HTML still unparsed. This + // re-derives from a growing, non-uniform sample, so it jitters up and down as the build crosses + // dense vs sparse regions of the chapter. + const uint64_t raw = static_cast(builtPageCount_) * total / consumed; + + // Damp that jitter with an exponential moving average. Step it once per build advance (keyed on + // bytesConsumed) rather than per status-bar redraw, so the smoothing rate doesn't depend on how + // often we repaint. As the build nears the end, consumed -> total and raw -> the built count, so + // the average settles onto the true count (and finalizeBuild then returns the exact pageCount). + constexpr float ALPHA = 0.25f; // weight of each new sample; lower = steadier but slower to settle + if (build_->smoothedEstimate <= 0) { + build_->smoothedEstimate = static_cast(raw); // seed on the first estimate + } else if (consumed != build_->smoothedAtConsumed) { + build_->smoothedEstimate += ALPHA * (static_cast(raw) - build_->smoothedEstimate); + } + build_->smoothedAtConsumed = consumed; + + const uint64_t est = static_cast(build_->smoothedEstimate + 0.5f); + if (est <= pageCount) return pageCount; // never fewer than the pages already available + return est > 60000 ? 60000 : static_cast(est); +} + +// Write the LUTs and anchor map into the open tmp .bin, patch the header with the built +// page count and table offsets, stamp `version` as the commit point, then swap the tmp +// file over filePath. For SECTION_FILE_PARTIAL_VERSION a watermark trailer +// (bytesConsumed, totalBytes) is appended after the li LUT so a later open can estimate +// the total page count. The parser must still be alive (anchors are read from it). +// On failure the tmp is removed and any pre-existing file at filePath is left intact. +bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsumed, const uint32_t totalBytes) { + const bool asPartial = (version == SECTION_FILE_PARTIAL_VERSION); + + const auto failCommit = [this]() { + // Explicit close() required before remove (member variable, O_RDWR handle). file.close(); - Storage.remove(filePath.c_str()); + Storage.remove(binTmpPath().c_str()); return false; + }; + + const uint32_t lutOffset = file.position(); + for (const auto& entry : build_->lut) { + if (entry.fileOffset == 0) { + LOG_ERR("SCT", "Failed to write LUT due to invalid page positions"); + return failCommit(); + } + serialization::writePod(file, entry.fileOffset); } - // Write anchor-to-page map for fragment navigation (e.g. footnote targets) + // Write anchor-to-page map for fragment navigation (e.g. footnote targets). For a + // partial, skip anchors that landed on the incomplete trailing page the suspend drops. const uint32_t anchorMapOffset = file.position(); - const auto& anchors = visitor.getAnchors(); - serialization::writePod(file, static_cast(anchors.size())); + const auto& anchors = build_->parser->getAnchors(); + uint16_t anchorCount = 0; + for (const auto& [anchor, page] : anchors) { + if (!asPartial || page < builtPageCount_) anchorCount++; + } + serialization::writePod(file, anchorCount); for (const auto& [anchor, page] : anchors) { + if (asPartial && page >= builtPageCount_) continue; serialization::writeString(file, anchor); serialization::writePod(file, page); } const uint32_t paragraphLutOffset = file.position(); - serialization::writePod(file, static_cast(lut.size())); - for (const auto& entry : lut) { + serialization::writePod(file, static_cast(build_->lut.size())); + for (const auto& entry : build_->lut) { serialization::writePod(file, entry.paragraphIndex); } const uint32_t liLutFileOffset = static_cast(file.position()); - for (const auto& entry : lut) { + for (const auto& entry : build_->lut) { serialization::writePod(file, entry.listItemIndex); } - // Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset - file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount)); - serialization::writePod(file, pageCount); + if (asPartial) { + // Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t). + serialization::writePod(file, bytesConsumed); + serialization::writePod(file, totalBytes); + } + + // Patch header with the built page count and section offsets... + file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(builtPageCount_)); + serialization::writePod(file, builtPageCount_); serialization::writePod(file, lutOffset); serialization::writePod(file, anchorMapOffset); serialization::writePod(file, paragraphLutOffset); serialization::writePod(file, liLutFileOffset); + // ...then commit by overwriting the sentinel version with the real one. Writing the + // version last makes it the commit point: a crash before here leaves version 0. + file.seek(0); + serialization::writePod(file, version); // Explicit close() required: member variable persists beyond function scope file.close(); - if (cssParser) { - cssParser->clear(); + + // Swap into place. A crash between remove and rename loses the old file but keeps a + // fully-committed tmp; the next build just removes it and rebuilds. + if (Storage.exists(filePath.c_str())) { + Storage.remove(filePath.c_str()); + } + if (!Storage.rename(binTmpPath().c_str(), filePath.c_str())) { + LOG_ERR("SCT", "Failed to move built section into place"); + Storage.remove(binTmpPath().c_str()); + return false; } return true; } -std::unique_ptr Section::loadPageFromSectionFile() { - if (!Storage.openFileForRead("SCT", filePath, file)) { +bool Section::finalizeBuild() { + // Flush the trailing page (emits the last page via the completePageFn into the LUT). + build_->parser->finishParse(); + + if (!build_->reusedHtml) { + // Parse succeeded: promote the freshly unzipped HTML to the persistent cache so future + // rebuilds skip zip inflation. If promotion fails, drop the temp -- the build still succeeded. + if (!Storage.rename(build_->tmpHtmlPath.c_str(), build_->htmlPath.c_str())) { + LOG_DBG("SCT", "Failed to promote HTML cache, removing temp"); + Storage.remove(build_->tmpHtmlPath.c_str()); + } + } + + const bool committed = commitBuildFile(SECTION_FILE_VERSION, 0, 0); + if (build_->cssParser) build_->cssParser->clear(); + build_.reset(); + if (!committed) { + // commitBuildFile removed filePath before the failed swap, so nothing valid remains. + partial_ = false; + partialPageCount_ = 0; + pageCount = 0; + builtPageCount_ = 0; + return false; + } + buildComplete_ = true; + partial_ = false; + partialPageCount_ = 0; + pageCount = builtPageCount_; + return true; +} + +void Section::suspendBuild() { + if (!build_) return; + + // Only worth persisting if this build produced pages a pre-existing partial doesn't + // already cover; otherwise keep the older (bigger) partial and just drop the tmp. + const bool worthKeeping = builtPageCount_ > 0 && (!partial_ || builtPageCount_ > partialPageCount_); + + bool committed = false; + if (worthKeeping) { + // Capture the parse watermark and commit before tearing the parser down (the anchor + // map is read from it). The incomplete trailing page is intentionally not flushed: + // only fully laid-out pages are persisted, and the rebuild re-derives the rest. + const uint32_t consumed = static_cast(build_->parser->parseBytesConsumed()); + committed = commitBuildFile(SECTION_FILE_PARTIAL_VERSION, consumed, build_->totalBytes); + if (committed) { + partial_ = true; + partialPageCount_ = builtPageCount_; + partialBytesConsumed_ = consumed; + partialTotalBytes_ = build_->totalBytes; + LOG_INF("SCT", "Suspended build: %u pages persisted", builtPageCount_); + } + } + + if (build_->parser) build_->parser->abortParse(); + if (build_->cssParser) build_->cssParser->clear(); + if (!committed && file) { + // Explicit close() required before remove (member variable, O_RDWR handle). + file.close(); + Storage.remove(binTmpPath().c_str()); + } + if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) { + Storage.remove(build_->tmpHtmlPath.c_str()); + } + build_.reset(); + buildComplete_ = false; + pageCount = partial_ ? partialPageCount_ : 0; + builtPageCount_ = 0; +} + +void Section::abandonBuild() { + if (!build_) return; + if (build_->parser) build_->parser->abortParse(); + if (build_->cssParser) build_->cssParser->clear(); + if (file) { + // Explicit close() required before remove (member variable, O_RDWR handle). + file.close(); + Storage.remove(binTmpPath().c_str()); + } + // A parse error would recur against the same HTML, so drop any partial too -- resuming + // from it would just re-enter the failing build every open. + if (Storage.exists(filePath.c_str())) { + Storage.remove(filePath.c_str()); + } + if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) { + Storage.remove(build_->tmpHtmlPath.c_str()); + } + build_.reset(); + buildComplete_ = false; + partial_ = false; + partialPageCount_ = 0; + pageCount = 0; + builtPageCount_ = 0; +} + +std::unique_ptr Section::loadPageDuringBuild(const int page) { + if (!build_ || page < 0 || page >= static_cast(build_->lut.size()) || !file) { + return nullptr; + } + const uint32_t pos = build_->lut[page].fileOffset; + if (pos == 0) { + return nullptr; + } + // The .bin is open O_RDWR for the build. Read the already-written page, then restore + // the write cursor so the next onPageComplete keeps appending where it left off. + const uint32_t writePos = file.position(); + file.seek(pos); + auto p = Page::deserialize(file); + file.seek(writePos); + return p; +} + +// Read a page from the committed file at filePath (finalized section or partial from a +// previous session). Uses a local handle so it is safe while a build holds the member +// `file` open on the tmp .bin. +std::unique_ptr Section::loadPageAt(const int page) const { + HalFile f; + if (!Storage.openFileForRead("SCT", filePath, f)) { return nullptr; } - file.seek(HEADER_SIZE - sizeof(uint32_t) * 4); + f.seek(HEADER_SIZE - sizeof(uint32_t) * 4); uint32_t lutOffset; - serialization::readPod(file, lutOffset); - file.seek(lutOffset + sizeof(uint32_t) * currentPage); + serialization::readPod(f, lutOffset); + f.seek(lutOffset + sizeof(uint32_t) * page); uint32_t pagePos; - serialization::readPod(file, pagePos); - file.seek(pagePos); + serialization::readPod(f, pagePos); + f.seek(pagePos); - auto page = Page::deserialize(file); - // Explicit close() required: member variable persists beyond function scope - file.close(); - return page; + return Page::deserialize(f); + // No f.close() needed -- DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit +} + +std::unique_ptr Section::loadPage(const int page) { + if (page < 0) { + return nullptr; + } + if (build_ && page < static_cast(build_->lut.size())) { + return loadPageDuringBuild(page); + } + // Not (yet) in the active build: serve from the file on disk -- a finalized section, + // or a partial from a previous session whose pages the rebuild hasn't reached again. + const int onDisk = partial_ ? partialPageCount_ : (build_ ? 0 : pageCount); + if (page >= onDisk) { + return nullptr; + } + return loadPageAt(page); } std::string Section::getTextFromSectionFile() { std::string fullText; - auto p = this->loadPageFromSectionFile(); + auto p = loadPage(currentPage); if (p) { for (const auto& el : p->elements) { if (el->getTag() == TAG_PageLine) { @@ -361,6 +756,15 @@ std::optional Section::getCachedPageCount() const { return std::nullopt; } + // Only a finalized section's count is the chapter total; a partial's count is just the + // suspended build's watermark, which would skew progress mapping. Callers fall back to + // their own estimates. + uint8_t version; + serialization::readPod(f, version); + if (version != SECTION_FILE_VERSION) { + return std::nullopt; + } + f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t)); uint16_t count; serialization::readPod(f, count); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index ef216608e9..d90b455899 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -3,11 +3,14 @@ #include #include #include +#include #include "Epub.h" class Page; class GfxRenderer; +class ChapterHtmlSlimParser; +class CssParser; class Section { std::shared_ptr epub; @@ -21,16 +24,67 @@ class Section { bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled); uint32_t onPageComplete(std::unique_ptr page); + // Page-offset table entry, kept in RAM while an incremental build is running so + // already-built pages can be located in the partially-written .bin. + struct PageLutEntry { + uint32_t fileOffset; + uint16_t paragraphIndex; + uint16_t listItemIndex; + }; + // Held only while an incremental build is in progress (see startBuild). Carries the + // live parser plus the strings it references (the parser stores them by reference) + // and the in-RAM page-offset table. + struct BuildContext { + std::unique_ptr parser; + std::vector lut; + std::string parsePath; + std::string contentBase; + std::string imageBasePath; + std::string htmlPath; + std::string tmpHtmlPath; + bool reusedHtml = false; + CssParser* cssParser = nullptr; + // HTML byte progress, for estimating the section's total page count while it's still building. + uint32_t bytesConsumed = 0; + uint32_t totalBytes = 0; + // Exponentially-smoothed page-count estimate (0 = not yet seeded) and the bytesConsumed at its + // last update. The raw byte-ratio estimate jitters as the build crosses dense/sparse regions; + // the EMA is stepped once per build advance (not per redraw) to damp that wobble. + float smoothedEstimate = 0; + uint32_t smoothedAtConsumed = 0; + }; + std::unique_ptr build_; + bool buildComplete_ = false; + // Pages laid out by the active build (== build_->lut.size()). Distinct from pageCount, + // which is the pages *available to read* and also counts a loaded partial file's pages. + uint16_t builtPageCount_ = 0; + // A partial section file (suspended build from a previous session) is loaded at filePath. + // Its pages 0..partialPageCount_-1 are readable while a rebuild extends past them. + bool partial_ = false; + uint16_t partialPageCount_ = 0; + // Parse watermark from the partial's trailer, for estimating the total page count. + uint32_t partialBytesConsumed_ = 0; + uint32_t partialTotalBytes_ = 0; + bool finalizeBuild(); + // Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the + // header, stamp the version byte, and swap the tmp .bin over filePath. + bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes); + // Builds write here and are swapped over filePath only on commit, so a prior + // partial/finalized file stays readable while a rebuild is in progress. + std::string binTmpPath() const { return filePath + ".part"; } + std::unique_ptr loadPageAt(int page) const; + // Read a page already laid out by the in-progress build (page < build LUT size), from + // the partially-written tmp .bin without disturbing the build's write cursor. + std::unique_ptr loadPageDuringBuild(int page); + 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") {} - ~Section() = default; + // Constructor and destructor are out-of-line: BuildContext holds a unique_ptr to the + // forward-declared ChapterHtmlSlimParser, whose full definition is only visible in the .cpp. + explicit Section(const std::shared_ptr& epub, int spineIndex, GfxRenderer& renderer); + ~Section(); bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled); @@ -39,12 +93,56 @@ class Section { uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled, const std::function& popupFn = nullptr); - std::unique_ptr loadPageFromSectionFile(); + + // Incremental build: lay out the section a few pages at a time so a large chapter + // can show its first page immediately and keep the UI responsive while the rest + // builds. createSectionFile() above is the one-shot wrapper over these. + // if (!startBuild(...)) fail; + // each tick: buildSomeMore(N); render up to pageCount; when isBuildComplete() stop. + bool startBuild(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, + uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, + uint8_t imageRendering, bool focusReadingEnabled, const std::function& popupFn = nullptr); + // Lay out up to maxPages more pages (maxPages <= 0 = build to completion). Returns + // false on error (the build is abandoned). Sets isBuildComplete() when finished. + bool buildSomeMore(int maxPages); + bool isBuilding() const { return static_cast(build_); } + bool isBuildComplete() const { return buildComplete_; } + // Best-known total page count: the exact pageCount once finalized, or a smoothed byte-based + // estimate (pages so far scaled by totalBytes/bytesConsumed, damped by an EMA) while a giant spine + // is still building, so "page X of Y" / progress don't read off the small build watermark. + uint16_t estimatedTotalPages() const; + void abandonBuild(); + // Persist an in-progress build as a partial section file (version sentinel + LUTs + + // watermark trailer) instead of discarding it, so the next open of this spine can show + // its pages instantly and only rebuild in the background. Called by the destructor, so + // any teardown path (exit, sleep, navigation) keeps the work already done. Keeps a + // pre-existing partial when it covers more pages than this build reached. + void suspendBuild(); + // True when a partial file was loaded: pageCount is a watermark, not the chapter total. + bool isPartial() const { return partial_; } + + // Unified page read: from the active build if it has reached the page, otherwise from + // the on-disk file (finalized section, or a partial the rebuild hasn't caught up to). + std::unique_ptr loadPage(int page); + std::string getTextFromSectionFile(); + // Resolve an anchor from the in-progress build first, then the on-disk anchor map + // (covers finalized sections and partials from a previous session). + std::optional findAnchor(const std::string& anchor) const; + + // True if this spine's unzipped HTML is already cached, so a build won't pay the (multi-second on a + // giant spine) zip inflation. Lets the reader skip the indexing popup on a fast reopen/rebuild. + bool hasHtmlCache() const; + // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; + // Look up an anchor among the pages built so far by the in-progress build, so an anchor jump + // (TOC / chapter select, usually the chapter top = page 0) can resolve without laying out the + // whole chapter. Returns nullopt if the anchor hasn't been reached yet (build more) or no build. + std::optional findAnchorDuringBuild(const std::string& anchor) const; + // Get the page count from the section cache file without fully loading it. std::optional getCachedPageCount() const; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 31b94fbf63..b04159e7ec 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1275,7 +1275,9 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n } } -bool ChapterHtmlSlimParser::parseAndBuildPages() { +ChapterHtmlSlimParser::~ChapterHtmlSlimParser() { abortParse(); } + +bool ChapterHtmlSlimParser::beginParse() { // Initialize block style stack with a root entry representing "no ancestor block elements". // The user's paragraph alignment is set as the default so child elements without explicit // text-align inherit it correctly through getCombinedBlockStyle. @@ -1293,67 +1295,78 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { paragraphAlignmentBlockStyle.alignment = align; startNewTextBlock(paragraphAlignmentBlockStyle); - XML_Parser parser = XML_ParserCreate(nullptr); - int done; - - if (!parser) { + xmlParser_ = XML_ParserCreate(nullptr); + if (!xmlParser_) { LOG_ERR("EHP", "Couldn't allocate memory for parser"); return false; } // Handle HTML entities (like  ) that aren't in XML spec or DTD // Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE - XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand); + XML_SetDefaultHandlerExpand(xmlParser_, defaultHandlerExpand); - HalFile file; - if (!Storage.openFileForRead("EHP", filepath, file)) { - destroyXmlParser(parser); + if (!Storage.openFileForRead("EHP", filepath, parseFile_)) { + destroyXmlParser(xmlParser_); + xmlParser_ = nullptr; return false; } // Get file size to decide whether to show indexing popup. - if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) { + if (popupFn && parseFile_.size() >= MIN_SIZE_FOR_POPUP) { popupFn(); } - XML_SetUserData(parser, this); - XML_SetElementHandler(parser, startElement, endElement); - XML_SetCharacterDataHandler(parser, characterData); + XML_SetUserData(xmlParser_, this); + XML_SetElementHandler(xmlParser_, startElement, endElement); + XML_SetCharacterDataHandler(xmlParser_, characterData); - // Compute the time taken to parse and build pages - const uint32_t chapterStartTime = millis(); - do { - void* const buf = XML_GetBuffer(parser, PARSE_BUFFER_SIZE); - if (!buf) { - LOG_ERR("EHP", "Couldn't allocate memory for buffer"); - destroyXmlParser(parser); - file.close(); - return false; - } + parseStartTime_ = millis(); + return true; +} + +ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() { + void* const buf = XML_GetBuffer(xmlParser_, PARSE_BUFFER_SIZE); + if (!buf) { + LOG_ERR("EHP", "Couldn't allocate memory for buffer"); + return ParseStatus::Error; + } - const size_t len = file.read(buf, PARSE_BUFFER_SIZE); + const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE); - if (len == 0 && file.available() > 0) { - LOG_ERR("EHP", "File read error"); - destroyXmlParser(parser); - file.close(); - return false; - } + if (len == 0 && parseFile_.available() > 0) { + LOG_ERR("EHP", "File read error"); + return ParseStatus::Error; + } - done = file.available() == 0; + const int done = parseFile_.available() == 0; - if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { - LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser), - XML_ErrorString(XML_GetErrorCode(parser))); - destroyXmlParser(parser); - file.close(); - return false; - } - } while (!done); - LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime); + if (XML_ParseBuffer(xmlParser_, static_cast(len), done) == XML_STATUS_ERROR) { + LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(xmlParser_), + XML_ErrorString(XML_GetErrorCode(xmlParser_))); + return ParseStatus::Error; + } - destroyXmlParser(parser); - file.close(); + return done ? ParseStatus::Done : ParseStatus::More; +} + +void ChapterHtmlSlimParser::abortParse() { + if (xmlParser_) { + destroyXmlParser(xmlParser_); + xmlParser_ = nullptr; + } + // Only close the file if it was successfully opened in beginParse() + if (parseFile_.isOpen()) { + parseFile_.close(); + } +} + +bool ChapterHtmlSlimParser::finishParse() { + if (xmlParser_) { + LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - parseStartTime_); + destroyXmlParser(xmlParser_); + xmlParser_ = nullptr; + } + parseFile_.close(); // Process last page if there is still text if (currentTextBlock) { @@ -1371,6 +1384,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return true; } +bool ChapterHtmlSlimParser::parseAndBuildPages() { + if (!beginParse()) { + return false; + } + for (;;) { + const ParseStatus status = parseStep(); + if (status == ParseStatus::Error) { + abortParse(); + return false; + } + if (status == ParseStatus::Done) { + break; + } + } + return finishParse(); +} + void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line) { const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 4e619ae34f..0571a7a427 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -96,6 +97,16 @@ class ChapterHtmlSlimParser { std::vector> pendingFootnotes; // int wordsExtractedInBlock = 0; + // Resumable parse state. The one-shot parseAndBuildPages() drives these + // internally; the incremental section builder drives them across render ticks + // so a large single chapter can yield between pages instead of blocking the UI + // until the whole thing is laid out. parseFile_ and the expat parser stay alive + // for the lifetime of the parse so it can be paused and resumed at buffer + // boundaries. + XML_Parser xmlParser_ = nullptr; + HalFile parseFile_; + uint32_t parseStartTime_ = 0; + void updateEffectiveInlineStyle(); void startNewTextBlock(const BlockStyle& blockStyle); void flushPendingAnchor(); @@ -144,8 +155,28 @@ class ChapterHtmlSlimParser { imageBasePath(imageBasePath), tocAnchors(std::move(tocAnchors)) {} - ~ChapterHtmlSlimParser() = default; + ~ChapterHtmlSlimParser(); + + // One-shot parse: builds every page before returning (begin + step* + finish). bool parseAndBuildPages(); + + // Resumable parse, for the incremental section builder. Drive as: + // if (!beginParse()) fail; + // loop: switch (parseStep()) { More: keep going / yield; Done: finishParse(); Error: abortParse(); } + // Pages are emitted via completePageFn as they complete during parseStep(), so + // the caller can stop once enough pages are built and resume on a later tick. + enum class ParseStatus { More, Done, Error }; + bool beginParse(); + ParseStatus parseStep(); + bool finishParse(); // flush the trailing page and tear down; returns true + void abortParse(); // tear down without flushing (error / abandon) + void addLineToPage(std::shared_ptr line); const std::vector>& getAnchors() const { return anchorData; } + + // Byte progress of the in-flight parse, used to estimate a still-building section's total page + // count (a giant single-spine book never fully lays out, so its real count is unknown). Valid + // between beginParse() and finishParse()/abortParse(). + size_t parseBytesConsumed() { return parseFile_ ? parseFile_.position() : 0; } + size_t parseTotalBytes() { return parseFile_ ? parseFile_.size() : 0; } }; diff --git a/lib/KOReaderSync/ChapterXPathResolver.cpp b/lib/KOReaderSync/ChapterXPathResolver.cpp index 5928ebd767..73877519da 100644 --- a/lib/KOReaderSync/ChapterXPathResolver.cpp +++ b/lib/KOReaderSync/ChapterXPathResolver.cpp @@ -278,13 +278,18 @@ class XPathParagraphResolver final : public Print { path.push_back({name, siblingIndex}); parentStates.emplace_back(); + // Count both

and

  • as paragraph-like positions, matching how the section + // layout tracks them (xpathParagraphIndex and xpathListItemIndex). This ensures + // KOReader progress in list items maps to the correct XPath. if (name == "p") { paragraphCount++; - if (paragraphCount == targetParagraph) { - xpath = buildParagraphXPath(spineIndex, path, 0, 0); - stopped = true; - XML_StopParser(parser, XML_FALSE); - } + } else if (name == "li") { + paragraphCount++; + } + if (paragraphCount == targetParagraph) { + xpath = buildParagraphXPath(spineIndex, path, 0, 0); + stopped = true; + XML_StopParser(parser, XML_FALSE); } depth++; diff --git a/lib/KOReaderSync/ProgressMapper.cpp b/lib/KOReaderSync/ProgressMapper.cpp index 2f1ab97f35..114af6a57b 100644 --- a/lib/KOReaderSync/ProgressMapper.cpp +++ b/lib/KOReaderSync/ProgressMapper.cpp @@ -709,12 +709,13 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr 1) ? static_cast(pos.pageNumber) / static_cast(pos.totalPages - 1) : 0.0f; result.percentage = epub->calculateProgress(pos.spineIndex, intra); - // Progress-based XPath correctly handles both

    and

  • positions. - result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra); - // Fall back to paragraph-index lookup when progress-based resolution fails. - if (result.xpath.empty() && pos.hasParagraphIndex && pos.paragraphIndex > 0) { + if (pos.hasParagraphIndex && pos.paragraphIndex > 0) { result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex); } + // Fall back to progress-based XPath, then synthetic progress mapping. + if (result.xpath.empty()) { + result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra); + } if (result.xpath.empty()) { result.xpath = generateXPath(epub, pos.spineIndex, intra); } diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index cc996967e4..31a9539838 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -43,6 +43,10 @@ struct PageResult { struct ProgressChangeResult { int spineIndex = 0; int page = 0; + int totalPages = 0; + std::string xpath; + float percentage = 0.0f; + bool hasSavedProgress = false; }; enum class NetworkMode; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 5948fd9ae5..e7f47deba9 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -238,6 +238,33 @@ void EpubReaderActivity::loop() { return; } + // Drive any in-progress incremental section build forward, off the page-turn critical path, + // but only within a small window ahead of the reader: an unbounded build monopolized the + // RenderLock and locked out page turns. The build follows the reader instead, and instant + // reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit. + // Skip while the render mutex is busy so we never delay a pending render; re-check + // isBuilding() under the lock since render() may have just finished it. + if (section && section->isBuilding() && !RenderLock::peek() && + static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) { + RenderLock lock; + // Re-check under the lock: render() (which also holds the RenderLock) may have finalized the + // build between the outer isBuilding() check and acquiring the lock here, in which case + // buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task + // mutation, so it flags this as always true. + // cppcheck-suppress knownConditionTrueFalse + if (section->isBuilding()) { + if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) { + LOG_ERR("ERS", "Background section build failed"); + section.reset(); + requestUpdate(); + } else if (section->isBuildComplete() && applyDeferredReposition()) { + // The chapter re-paginated since the saved progress (settings changed): we now know the + // real page count, so re-render at the remapped page. No-op for an unchanged resume. + requestUpdate(); + } + } + } + // End-of-Book screen reached (currentSpineIndex == spine count) means the book is // finished. Two independent finished-book features key off this same condition. const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount(); @@ -306,10 +333,11 @@ void EpubReaderActivity::loop() { ignoreNextConfirmRelease = false; } else { const int currentPage = section ? section->currentPage + 1 : 0; - const int totalPages = section ? section->pageCount : 0; + const int totalPages = section ? section->estimatedTotalPages() : 0; float bookProgress = 0.0f; - if (epub->getBookSize() > 0 && section && section->pageCount > 0) { - const float chapterProgress = static_cast(section->currentPage) / static_cast(section->pageCount); + if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) { + const float chapterProgress = + static_cast(section->currentPage) / static_cast(section->estimatedTotalPages()); bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; } const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); @@ -537,11 +565,32 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction loadCachedBookmarks(); if (!result.isCancelled) { const auto& sync = std::get(result.data); - if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { + int targetSpineIndex = sync.spineIndex; + int targetPage = sync.page; + const int activeTotalPages = section ? section->estimatedTotalPages() : 0; + const bool cachedPageMatchesActiveSection = section && sync.totalPages > 0 && + currentSpineIndex == sync.spineIndex && sync.page >= 0 && + sync.page < sync.totalPages && activeTotalPages == sync.totalPages; + + if (!cachedPageMatchesActiveSection && sync.hasSavedProgress) { + const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount; + CrossPointPosition fallback = + ProgressMapper::toCrossPoint(epub, {sync.xpath, sync.percentage}, renderer, currentSpineIndex, totalPages); + targetSpineIndex = fallback.spineIndex; + targetPage = fallback.pageNumber; + } + + if (currentSpineIndex != targetSpineIndex) { RenderLock lock(*this); - currentSpineIndex = sync.spineIndex; - nextPageNumber = sync.page; + currentSpineIndex = targetSpineIndex; + nextPageNumber = targetPage; section.reset(); + } else if (section && section->currentPage != targetPage) { + RenderLock lock(*this); + const int clampedTargetPage = std::max(0, targetPage); + section->currentPage = clampedTargetPage; + } else if (!section) { + nextPageNumber = targetPage; } } }; @@ -661,7 +710,7 @@ bool EpubReaderActivity::launchKOReaderSync() { if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch const int currentPage = section ? section->currentPage : nextPageNumber; - const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount; + const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount; std::optional paragraphIndex; if (section && currentPage >= 0 && currentPage < section->pageCount) { const uint16_t paragraphPage = @@ -759,7 +808,12 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (isForwardTurn) { - if (section->currentPage < section->pageCount - 1) { + // Advance within the section while there are (or may still be) more pages: either a built + // page ahead, or the section is still building (windowed), in which case more pages exist + // beyond the current watermark and render()'s ensure-built pump will lay them out. Only when + // the section is fully built AND we're on its last page do we move to the next spine -- using + // the live pageCount alone would mistake the build watermark for the end of a giant spine. + if (section->currentPage < section->pageCount - 1 || section->isBuilding()) { section->currentPage++; } else { // We don't want to delete the section mid-render, so grab the semaphore @@ -847,48 +901,130 @@ void EpubReaderActivity::render(RenderLock&& lock) { LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex); section = std::unique_ptr
    (new Section(epub, currentSpineIndex, renderer)); - if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { - LOG_DBG("ERS", "Cache not found, building..."); - - GUI.drawPopup(renderer, tr(STR_INDEXING)); - - const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); }; + // A finalized cache serves every page as-is. A partial cache (suspended build from a + // previous session) serves its pages instantly too, but a build must still run to lay + // out the rest -- it re-parses from the top in the background (HTML already cached, + // pages are deterministic) and finalizes, so the partial machinery retires itself. + const bool cacheLoaded = section->loadSectionFile( + SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, + SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, + SETTINGS.imageRendering, SETTINGS.focusReadingEnabled); + if (cacheLoaded) { + // Matching render params means identical pagination, so the saved page number is valid + // as-is: consume any pending settings-change reposition. Without this, a chapter total + // saved while the section was still building (i.e. a watermark, not the real count) + // would remap the resume page against the finalized count and teleport the reader. + cachedChapterTotalPageCount = 0; + } + const bool cacheComplete = cacheLoaded && !section->isPartial(); + if (!cacheComplete) { + if (section->isPartial()) { + LOG_DBG("ERS", "Partial cache found (%d pages), resuming build...", section->pageCount); + } else { + LOG_DBG("ERS", "Cache not found, building..."); + } - if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) { - LOG_ERR("ERS", "Failed to persist page data to SD"); - section.reset(); - showPendingSyncSaveError(); - return; + // Jumps that need the final pagination or the anchor map -- explicit page jumps, + // fragment anchors, percent jumps, and cross-setting progress repositioning -- can't + // resolve their landing page until the whole chapter is laid out, so they take the full + // (blocking) build with the indexing popup. Everything else -- plain forward reads, resume, + // and explicit page jumps -- only needs a specific page, so it builds incrementally to that + // page and finishes the rest in loop(). The settings-change reposition (cachedChapterTotal*) + // is NOT a full-build trigger: it's deferred to applyDeferredReposition() once the real page + // count is known, so it never blocks the first page. + // Only a percent jump truly needs the whole chapter up front (percent -> page needs the final + // page count). Anchor jumps (TOC / chapter select / footnotes) resolve incrementally below -- + // the anchor is recorded as its page is laid out, so a chapter-top anchor lands on page 0 + // without indexing the whole chapter. + const bool needsFullBuild = pendingPercentJump; + if (needsFullBuild) { + GUI.drawPopup(renderer, tr(STR_INDEXING)); + // The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF + // ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page. + pagesUntilFullRefresh = 1; + const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); }; + if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, + SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) { + LOG_ERR("ERS", "Failed to persist page data to SD"); + section.reset(); + showPendingSyncSaveError(); + return; + } + } else { + // Lay out just enough to show the landing page; loop() builds the rest behind it. Show the + // indexing popup up front only when the build will actually be slow: a large spine (its + // whole HTML must be inflated before page 1 can lay out -- the giant single-spine case), or + // a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections + // build in a blink and stay popup-free. + const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber); + const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) - + (currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0); + // Popup only when the build will actually be slow: a big spine whose HTML still needs + // inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds + // fast, so no popup -- that's what made an already-indexed book look like it was reindexing. + // A partial cache that already covers the target page shows it instantly: never popup. + const bool willInflate = !section->hasHtmlCache(); + const bool anchorJump = !pendingAnchor.empty(); + bool showPopup; + if (anchorJump) { + // An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already + // in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it + // lies beyond the indexed watermark and the build may lay out the whole spine to find it, + // so gate on spine size alone -- laying out a big spine takes seconds even with cached + // HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free. + showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD; + } else { + const bool targetAvailable = target < static_cast(section->pageCount); + showPopup = !targetAvailable && + ((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD); + } + if (showPopup) { + GUI.drawPopup(renderer, tr(STR_INDEXING)); + // HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page. + pagesUntilFullRefresh = 1; + } + if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, + SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { + LOG_ERR("ERS", "Failed to start section build"); + section.reset(); + showPendingSyncSaveError(); + return; + } + while (!section->isBuildComplete() && + (anchorJump ? !section->findAnchor(pendingAnchor) : static_cast(section->pageCount) <= target)) { + // Anchor jump: build until the anchor's page is laid out (usually page 0), checking a + // partial's on-disk anchor map too so an already-indexed anchor resolves immediately. + // Otherwise: build until the target page exists. loop() builds the rest behind it. + if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) { + LOG_ERR("ERS", "Failed during incremental section build"); + section.reset(); + showPendingSyncSaveError(); + return; + } + } } } else { LOG_DBG("ERS", "Cache found, skipping build..."); } if (pendingPageJump.has_value()) { - if (*pendingPageJump >= section->pageCount && section->pageCount > 0) { - section->currentPage = section->pageCount - 1; - } else { - section->currentPage = *pendingPageJump; - } + section->currentPage = *pendingPageJump; pendingPageJump.reset(); } else { section->currentPage = nextPageNumber; if (section->currentPage < 0) { section->currentPage = 0; - } else if (section->currentPage >= section->pageCount && section->pageCount > 0) { - LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1); - section->currentPage = section->pageCount - 1; } } if (!pendingAnchor.empty()) { - if (const auto page = section->getPageForAnchor(pendingAnchor)) { + // Resolve from the pages laid out so far and/or the on-disk map (finalized or partial). + const auto page = section->findAnchor(pendingAnchor); + if (page) { section->currentPage = *page; LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page); } else { @@ -897,17 +1033,6 @@ void EpubReaderActivity::render(RenderLock&& lock) { pendingAnchor.clear(); } - // handles changes in reader settings and reset to approximate position based on cached progress - if (cachedChapterTotalPageCount > 0) { - // only goes to relative position if spine index matches cached value - if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) { - float progress = static_cast(section->currentPage) / static_cast(cachedChapterTotalPageCount); - int newPage = static_cast(progress * section->pageCount); - section->currentPage = newPage; - } - cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again - } - if (pendingPercentJump && section->pageCount > 0) { // Apply the pending percent jump now that we know the new section's page count. int newPage = static_cast(pendingSpineProgress * static_cast(section->pageCount)); @@ -919,6 +1044,57 @@ void EpubReaderActivity::render(RenderLock&& lock) { } } + // Extend the build to the requested page if needed (for partials and in-progress builds). + // This runs every render, so it covers both the first page and any forward turn that gets + // ahead of the background builder; pages already built do no work here. + while (section->isPartial() && section->currentPage >= static_cast(section->pageCount)) { + // Start a build to extend a partial toward the requested page. + if (!section->isBuilding() && + !section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, + SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, + SETTINGS.focusReadingEnabled)) { + LOG_ERR("ERS", "Failed to start partial extension build"); + section.reset(); + showPendingSyncSaveError(); + return; + } + // Extend until either the target page exists or the build completes. + while (!section->isBuildComplete() && section->currentPage >= static_cast(section->pageCount)) { + if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) { + LOG_ERR("ERS", "Failed during incremental section build"); + section.reset(); + showPendingSyncSaveError(); + return; + } + } + } + // For an in-progress incremental build, make sure the page we're about to show has been laid out. + if (section->isBuilding()) { + while (!section->isBuildComplete() && section->currentPage >= static_cast(section->pageCount)) { + if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) { + LOG_ERR("ERS", "Failed during incremental section build"); + section.reset(); + showPendingSyncSaveError(); + return; + } + } + } + + // The requested page is now as built as it will get. If it still lands past the end, + // clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter + // navigation, an explicit jump beyond a finished chapter, or a stale saved position. + // Guarded on !isBuilding() because a still-building section's pageCount is only the current + // watermark (not the final count) and has already been driven far enough by the loops above. + if (!section->isBuilding() && section->pageCount > 0 && + section->currentPage >= static_cast(section->pageCount)) { + section->currentPage = section->pageCount - 1; + } + + // Apply a deferred settings-change reposition now that the real page count is known (a no-op for + // a plain resume / unchanged pagination). If still building, this defers to loop() on completion. + applyDeferredReposition(); + renderer.clearScreen(); if (section->pageCount == 0) { @@ -944,9 +1120,14 @@ void EpubReaderActivity::render(RenderLock&& lock) { updateBookmarkFlag(); { - auto p = section->loadPageFromSectionFile(); + // Unified page read: the in-progress build's in-RAM table if it has reached the page, + // otherwise the on-disk file (finalized section, or a partial from a previous session). + auto p = section->loadPage(section->currentPage); if (!p) { LOG_ERR("ERS", "Failed to load page from SD - clearing section cache"); + // Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files, + // and the destructor's suspend would otherwise commit tables into a deleted handle. + section->abandonBuild(); section->clearCache(); section.reset(); requestUpdate(); // Try again after clearing cache @@ -963,8 +1144,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); LOG_DBG("ERS", "Rendered page in %dms", millis() - start); } - silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight); - saveProgress(currentSpineIndex, section->currentPage, section->pageCount); + saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages()); showPendingSyncSaveError(); @@ -978,36 +1158,28 @@ void EpubReaderActivity::render(RenderLock&& lock) { } } -void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) { - if (!epub || !section || section->pageCount < 2) { - return; - } - - // Build the next chapter cache while the penultimate page is on screen. - if (section->currentPage != section->pageCount - 2) { - return; - } - - const int nextSpineIndex = currentSpineIndex + 1; - if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) { - return; - } - - Section nextSection(epub, nextSpineIndex, renderer); - if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { - return; - } - - LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex); - if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { - LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex); +bool EpubReaderActivity::applyDeferredReposition() { + if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) { + return false; + } + bool changed = false; + // Only remap when the chapter actually re-paginated (e.g. after a settings change). A plain + // resume has identical pagination, so section->pageCount == cachedChapterTotalPageCount and + // nothing moves. + if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) { + const float progress = static_cast(section->currentPage) / static_cast(cachedChapterTotalPageCount); + int newPage = static_cast(progress * static_cast(section->pageCount)); + if (newPage < 0) newPage = 0; + if (section->pageCount > 0 && newPage >= static_cast(section->pageCount)) { + newPage = section->pageCount - 1; + } + if (newPage != section->currentPage) { + section->currentPage = newPage; + changed = true; + } } + cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again + return changed; } bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { @@ -1180,9 +1352,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or } void EpubReaderActivity::renderStatusBar() const { - // Calculate progress in book + // Calculate progress in book. Use the estimated total while a giant spine is still building so + // "page X of Y" and the progress bar don't read off the small build watermark. const int currentPage = section->currentPage + 1; - const float pageCount = section->pageCount; + const float pageCount = section->estimatedTotalPages(); const float sectionChapterProg = (pageCount > 0) ? (static_cast(currentPage) / pageCount) : 0; const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; @@ -1213,7 +1386,8 @@ void EpubReaderActivity::renderStatusBar() const { title = epub->getTitle(); } - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked); + GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked, + section->isBuilding()); } void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) { @@ -1304,7 +1478,7 @@ void EpubReaderActivity::addBookmark() { int pageCount; { RenderLock lock(*this); - pageCount = section->pageCount; + pageCount = section->estimatedTotalPages(); currentPage = section->currentPage; } @@ -1353,10 +1527,10 @@ void EpubReaderActivity::updateBookmarkFlag() { currentPageBookmarked = false; return; } - const ProgressRange pageRange = - getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount); + const int pageCount = section->estimatedTotalPages(); + const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount); currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) { - return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, section->pageCount, pageRange); + return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange); }); } @@ -1369,9 +1543,9 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const { } if (section) { info.currentPage = section->currentPage + 1; - info.totalPages = section->pageCount; - if (epub && epub->getBookSize() > 0 && section->pageCount > 0) { - const float chapterProgress = static_cast(section->currentPage) / static_cast(section->pageCount); + info.totalPages = section->estimatedTotalPages(); + if (epub && epub->getBookSize() > 0 && info.totalPages > 0) { + const float chapterProgress = static_cast(section->currentPage) / static_cast(info.totalPages); int pct = static_cast(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f); if (pct < 0) pct = 0; if (pct > 100) pct = 100; @@ -1383,7 +1557,7 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const { CrossPointPosition EpubReaderActivity::getCurrentPosition() const { const int currentPage = section ? section->currentPage : nextPageNumber; - const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount; + const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount; std::optional paragraphIndex; if (section && currentPage >= 0 && currentPage < section->pageCount) { const uint16_t paragraphPage = diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 7e2a4ad294..691a0b07db 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -59,7 +59,31 @@ class EpubReaderActivity final : public Activity { void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar() const; - void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight); + // Pages laid out per incremental-build pump: on the render path (catching up to the page + // being shown) and per loop() tick (background build of a large chapter). Kept small so a + // background build chunk never noticeably delays input or a pending render. + static constexpr int BUILD_PAGES_PER_CHUNK = 8; + static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2; + // How many pages to keep laid out ahead of the reader for a still-building section. A page + // turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder + // -- a tiny buffer is enough. The background build stops once the watermark is this far + // ahead and resumes as the reader advances; building unbounded instead locked up input by + // monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin + // in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages + // already laid out as a partial file on exit/sleep. + static constexpr int BUILD_WINDOW_AHEAD = 5; + // Show the indexing popup when an initial build must lay out more than this many pages up front + // (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent + // of the small look-ahead window so ordinary landings stay popup-free. + static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20; + // Also show the popup when first building a spine larger than this (uncompressed bytes): its + // whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is + // a multi-second wait. Normal chapters are well under this and stay popup-free. + static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024; + // Remap the cached relative reading position once the section's real page count is known + // (used after a settings change re-paginates a chapter). Returns true if currentPage moved. + // No-op while the section is still building or when the pagination is unchanged (plain resume). + bool applyDeferredReposition(); bool saveProgress(int spineIndex, int currentPage, int pageCount); // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); diff --git a/src/activities/reader/EpubReaderBookmarksActivity.cpp b/src/activities/reader/EpubReaderBookmarksActivity.cpp index 0b1f3f30dc..fcf44cf39c 100644 --- a/src/activities/reader/EpubReaderBookmarksActivity.cpp +++ b/src/activities/reader/EpubReaderBookmarksActivity.cpp @@ -9,7 +9,6 @@ #include #include "MappedInputManager.h" -#include "ProgressMapper.h" #include "components/UITheme.h" #include "fontIds.h" @@ -108,8 +107,17 @@ void EpubReaderBookmarksActivity::loop() { return; } auto bookmark = bookmarks.at(selectorIndex); - CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer); - setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber}); + ProgressChangeResult result{}; + result.xpath = bookmark.xpath; + result.percentage = bookmark.percentage; + result.hasSavedProgress = true; + if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount && + bookmark.computedSpineIndex < epub->getSpineItemsCount()) { + result.spineIndex = bookmark.computedSpineIndex; + result.page = bookmark.computedChapterProgress; + result.totalPages = bookmark.computedChapterPageCount; + } + setResult(std::move(result)); finish(); return; } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index e4c040df91..001e33400c 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "CrossPointSettings.h" @@ -14,6 +15,7 @@ #include "XtcReaderActivity.h" #include "activities/util/BmpViewerActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "components/UITheme.h" bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); } @@ -35,6 +37,12 @@ std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { LOG_ERR("READER", "Failed to allocate EPUB object"); return nullptr; } + // First open: building the spine/TOC index (book.bin) takes a couple of seconds. Show the + // indexing popup so it isn't a silent wait on the home screen. The cachePath/hash is known at + // construction, so this check is valid before load(); a cached open loads in a blink -> no popup. + if (!Storage.exists((epub->getCachePath() + "/book.bin").c_str())) { + GUI.drawPopup(renderer, tr(STR_INDEXING)); + } if (epub->load(true, SETTINGS.embeddedStyle == 0)) { return epub; } diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 52625ecc6e..251030f310 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -11,7 +11,8 @@ class Txt; class ReaderActivity final : public Activity { std::string initialBookPath; std::string currentBookPath; // Track current book path for navigation - static std::unique_ptr loadEpub(const std::string& path); + // Non-static (unlike the other loaders): draws the first-open indexing popup, which needs the renderer. + std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadXtc(const std::string& path); static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index b2bbc21c16..5fe6e54501 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -749,7 +749,7 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom, const int textYOffset, - const bool fillMargin, const bool isPageBookmarked) const { + const bool fillMargin, const bool isPageBookmarked, const bool pageCountEstimated) const { auto metrics = UITheme::getInstance().getMetrics(); int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, @@ -769,12 +769,16 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c // Right aligned text for progress counter char progressStr[32]; + // Prefix the page count with "~" while a still-building spine only yields an estimated total. + const char* estimatePrefix = pageCountEstimated ? "~" : ""; + if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) { - snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress); + snprintf(progressStr, sizeof(progressStr), "%s%d/%d %.0f%%", estimatePrefix, currentPage, pageCount, + bookProgress); } else if (SETTINGS.statusBarBookProgressPercentage) { snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress); } else { - snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount); + snprintf(progressStr, sizeof(progressStr), "%s%d/%d", estimatePrefix, currentPage, pageCount); } int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 46d0bdaf8f..b50bfc6d24 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -237,7 +237,8 @@ class BaseTheme { virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom = 0, const int textYOffset = 0, - const bool fillMargin = true, const bool isPageBookmarked = false) const; + const bool fillMargin = true, const bool isPageBookmarked = false, + const bool pageCountEstimated = false) const; void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const; From 3e1dd31e53f71694b9af015825385bf9c2fee817 Mon Sep 17 00:00:00 2001 From: Tom-Inge Larsen Date: Sat, 4 Jul 2026 22:12:59 +0200 Subject: [PATCH 03/68] feat(reader): End of Book next-book suggestions (#2499) (#2532) --- lib/FsHelpers/FsHelpers.cpp | 88 +++++++------- lib/FsHelpers/FsHelpers.h | 4 + lib/I18n/translations/english.yaml | 2 + src/activities/reader/EndOfBookOptions.cpp | 115 +++++++++++++++++++ src/activities/reader/EndOfBookOptions.h | 48 ++++++++ src/activities/reader/EpubReaderActivity.cpp | 88 ++++++++++---- src/activities/reader/EpubReaderActivity.h | 5 + src/activities/reader/XtcReaderActivity.cpp | 66 +++++++++-- src/activities/reader/XtcReaderActivity.h | 5 + src/util/NextBookFinder.cpp | 85 ++++++++++++++ src/util/NextBookFinder.h | 15 +++ 11 files changed, 446 insertions(+), 75 deletions(-) create mode 100644 src/activities/reader/EndOfBookOptions.cpp create mode 100644 src/activities/reader/EndOfBookOptions.h create mode 100644 src/util/NextBookFinder.cpp create mode 100644 src/util/NextBookFinder.h diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 6b31563374..6f686688c3 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -79,6 +79,53 @@ std::string normalisePath(const std::string& path) { return result; } +bool naturalLess(const std::string& str1, const std::string& str2) { + // Naive natural sort: numeric-aware, case-insensitive + const char* s1 = str1.c_str(); + const char* s2 = str2.c_str(); + + // ctype functions require unsigned char values: passing a negative char (UTF-8 + // bytes above 0x7f with signed char) is undefined behavior + const auto isDigit = [](const char c) { return isdigit(static_cast(c)) != 0; }; + + // Iterate while both strings have characters + while (*s1 && *s2) { + // Check if both are at the start of a number + if (isDigit(*s1) && isDigit(*s2)) { + // Skip leading zeros and track them + while (*s1 == '0') s1++; + while (*s2 == '0') s2++; + + // Count digits to compare lengths first + int len1 = 0, len2 = 0; + while (isDigit(s1[len1])) len1++; + while (isDigit(s2[len2])) len2++; + + // Different length so return smaller integer value + if (len1 != len2) return len1 < len2; + + // Same length so compare digit by digit + for (int i = 0; i < len1; i++) { + if (s1[i] != s2[i]) return s1[i] < s2[i]; + } + + // Numbers equal so advance pointers + s1 += len1; + s2 += len2; + } else { + // Regular case-insensitive character comparison + const int c1 = tolower(static_cast(*s1)); + const int c2 = tolower(static_cast(*s2)); + if (c1 != c2) return c1 < c2; + s1++; + s2++; + } + } + + // One string is prefix of other + return *s1 == '\0' && *s2 != '\0'; +} + void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { // Directories first @@ -86,46 +133,7 @@ void sortFileList(std::vector& strs) { bool isDir2 = str2.back() == '/'; if (isDir1 != isDir2) return isDir1; - // Start naive natural sort - const char* s1 = str1.c_str(); - const char* s2 = str2.c_str(); - - // Iterate while both strings have characters - while (*s1 && *s2) { - // Check if both are at the start of a number - if (isdigit(*s1) && isdigit(*s2)) { - // Skip leading zeros and track them - while (*s1 == '0') s1++; - while (*s2 == '0') s2++; - - // Count digits to compare lengths first - int len1 = 0, len2 = 0; - while (isdigit(s1[len1])) len1++; - while (isdigit(s2[len2])) len2++; - - // Different length so return smaller integer value - if (len1 != len2) return len1 < len2; - - // Same length so compare digit by digit - for (int i = 0; i < len1; i++) { - if (s1[i] != s2[i]) return s1[i] < s2[i]; - } - - // Numbers equal so advance pointers - s1 += len1; - s2 += len2; - } else { - // Regular case-insensitive character comparison - char c1 = tolower(*s1); - char c2 = tolower(*s2); - if (c1 != c2) return c1 < c2; - s1++; - s2++; - } - } - - // One string is prefix of other - return *s1 == '\0' && *s2 != '\0'; + return naturalLess(str1, str2); }); } diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index 728b190f3d..87abd5444c 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -11,6 +11,10 @@ std::string decodeUriEscapes(const std::string& path); std::string normalisePath(const std::string& path); +// Numeric-aware, case-insensitive comparison ("2" < "10"). Returns true when str1 orders +// before str2. Same ordering sortFileList applies within the file/directory groups. +bool naturalLess(const std::string& str1, const std::string& str2); + void sortFileList(std::vector& strs); /** diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index d9174a2abf..569646a4d1 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -70,6 +70,8 @@ STR_IMAGES: "Images" STR_IMAGES_DISPLAY: "Display" STR_IMAGES_PLACEHOLDER: "Placeholder" STR_IMAGES_SUPPRESS: "Suppress" +STR_EOB_HOME: "Home" +STR_EOB_CONTINUE_WITH: "Continue with" STR_SHORT_PWR_BTN: "Short Power Button Click" STR_ORIENTATION: "Reading Orientation" STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)" diff --git a/src/activities/reader/EndOfBookOptions.cpp b/src/activities/reader/EndOfBookOptions.cpp new file mode 100644 index 0000000000..e76b0e9c13 --- /dev/null +++ b/src/activities/reader/EndOfBookOptions.cpp @@ -0,0 +1,115 @@ +#include "EndOfBookOptions.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "ReaderUtils.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/ButtonNavigator.h" +#include "util/NextBookFinder.h" + +namespace { +// Display name without the file extension, mirroring the file browser rows +std::string displayName(const std::string& filename) { + const auto pos = filename.rfind('.'); + return filename.substr(0, pos); +} +} // namespace + +void EndOfBookOptions::loadOnce(const std::string& currentBookPath) { + if (isLoaded.load(std::memory_order_acquire)) { + return; + } + folder = FsHelpers::extractFolderPath(currentBookPath); + names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS); + selector = 0; + // Release-publish so the main task, which gates all access on isLoaded, never + // observes a partially built list + isLoaded.store(true, std::memory_order_release); +} + +bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); } + +std::string EndOfBookOptions::fullPath(const size_t index) const { + if (index >= names.size()) { + return {}; + } + return folder == "/" ? "/" + names[index] : folder + "/" + names[index]; +} + +EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) { + if (input.wasReleased(MappedInputManager::Button::Confirm)) { + if (selector < static_cast(names.size())) { + if (openPath) { + *openPath = fullPath(selector); + } + return Action::OpenBook; + } + return Action::GoHome; // "Home" entry selected + } + + // Short-press Back returns to the last page; a long press falls through to the + // reader's own handler (file browser). Home is reached through the list's Home entry. + if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) { + return Action::LastPage; + } + + // Selection movement on the standard list navigation buttons (side Up/Down plus front + // Left/Right, orientation swap included). It follows the reader's page-turn semantics + // (press-triggered by default, release-triggered when a long-press behavior is + // configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with + // press-triggered turns, the press that turned the final page already fired in the + // reader, and its release must not double-fire into this menu. + const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF; + const auto triggered = [&](const MappedInputManager::Button button) { + return usePress ? input.wasPressed(button) : input.wasReleased(button); + }; + const int itemCount = static_cast(names.size()) + 1; // + "Home" entry + if (triggered(MappedInputManager::Button::NavPrevious)) { + selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom + return Action::Redraw; + } + if (triggered(MappedInputManager::Button::NavNext)) { + selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top + return Action::Redraw; + } + return Action::None; +} + +void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const { + const auto& metrics = UITheme::getInstance().getMetrics(); + + if (!menuActive()) { + // No suggestions: the historical plain end screen. 3/8 of the screen height matches + // the previous fixed position on the 480x800 panel and scales to other resolutions. + renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true, + EpdFontFamily::BOLD); + return; + } + + // Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at + // the physical front buttons, which is a logical side/top edge in the rotated + // orientations — lay out inside the safe area so nothing hides behind them. Vertical + // positions derive from the safe-area height and font line heights so other panel + // resolutions scale (review request on #2532). + const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); + const int titleY = safe.y + safe.height / 8; + const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing; + const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2; + + UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); + UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH)); + + const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing; + GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast(names.size()) + 1, selector, + [this](const int index) { + return index < static_cast(names.size()) ? displayName(names[index]) + : std::string(tr(STR_EOB_HOME)); + }); + + const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/reader/EndOfBookOptions.h b/src/activities/reader/EndOfBookOptions.h new file mode 100644 index 0000000000..ed0ff940ea --- /dev/null +++ b/src/activities/reader/EndOfBookOptions.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +class GfxRenderer; +class MappedInputManager; + +// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to +// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and +// draws the end screen. With no suggestions the end screen keeps its historical +// plain-title look and behavior. +class EndOfBookOptions { + public: + enum class Action { None, Redraw, OpenBook, GoHome, LastPage }; + + static constexpr size_t MAX_SUGGESTIONS = 3; + + // Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from + // the reader's render() (the render task, serialized by RenderLock) — the loaded flag + // is the release/acquire publication point that lets the main task read the finished + // list safely. + void loadOnce(const std::string& currentBookPath); + + // True when the suggestion menu is showing and should own the reader's input. + bool menuActive() const; + + // Menu input handling, following the standard list idiom: side Up/Down and front + // Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short + // Back press returns to the last page of the book. Fills openPath when the result is + // OpenBook. Returns Action::None when nothing relevant was pressed; callers continue + // their normal input path (keeping long-press Back to the file browser working). + Action handleMenuInput(const MappedInputManager& input, std::string* openPath); + + // Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer. + void render(GfxRenderer& renderer, const MappedInputManager& input) const; + + private: + std::string folder; + // Written by the render task in loadOnce(), immutable afterwards; the main task only + // reads it after isLoaded is observed true (acquire), so no further locking is needed. + std::vector names; + int selector = 0; + std::atomic isLoaded{false}; + + std::string fullPath(size_t index) const; +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e7f47deba9..dcc75cd2bf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -231,6 +231,30 @@ void EpubReaderActivity::onExit() { } } +void EpubReaderActivity::openReaderMenu() { + const int currentPage = section ? section->currentPage + 1 : 0; + const int totalPages = section ? section->estimatedTotalPages() : 0; + float bookProgress = 0.0f; + if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) { + const float chapterProgress = + static_cast(section->currentPage) / static_cast(section->estimatedTotalPages()); + bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; + } + const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); + startActivityForResult(std::make_unique( + renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, + SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()), + [this](const ActivityResult& result) { + // Always apply orientation change even if the menu was cancelled + const auto& menu = std::get(result.data); + applyOrientation(menu.orientation); + toggleAutoPageTurn(menu.pageTurnOption); + if (!result.isCancelled) { + onReaderMenuConfirm(static_cast(menu.action)); + } + }); +} + void EpubReaderActivity::loop() { if (!epub) { // Should never happen @@ -325,6 +349,35 @@ void EpubReaderActivity::loop() { requestUpdate(); } + // While the end screen suggestion menu is showing it owns Confirm/Back/navigation + // input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls + // through to the regular handlers below; page turns are absorbed by the end-of-book + // block. A Confirm release after a long-press function (bookmark/sync) fired is left + // to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease. + if (atEndOfBook && endOfBookOptions.menuActive() && + !(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) { + std::string openPath; + switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) { + case EndOfBookOptions::Action::OpenBook: + activityManager.goToReader(openPath); + return; + case EndOfBookOptions::Action::GoHome: + onGoHome(); + return; + case EndOfBookOptions::Action::LastPage: + currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0); + nextPageNumber = 0; + pendingPageJump = std::numeric_limits::max(); + requestUpdate(); + return; + case EndOfBookOptions::Action::Redraw: + requestUpdate(); + return; + case EndOfBookOptions::Action::None: + break; + } + } + // Enter reader menu activity on short-press Confirm. A long-press that fired a bound // function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release // following the hold does not also open the menu. @@ -332,27 +385,7 @@ void EpubReaderActivity::loop() { if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; } else { - const int currentPage = section ? section->currentPage + 1 : 0; - const int totalPages = section ? section->estimatedTotalPages() : 0; - float bookProgress = 0.0f; - if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) { - const float chapterProgress = - static_cast(section->currentPage) / static_cast(section->estimatedTotalPages()); - bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; - } - const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); - startActivityForResult(std::make_unique( - renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()), - [this](const ActivityResult& result) { - // Always apply orientation change even if the menu was cancelled - const auto& menu = std::get(result.data); - applyOrientation(menu.orientation); - toggleAutoPageTurn(menu.pageTurnOption); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + openReaderMenu(); } } @@ -433,8 +466,14 @@ void EpubReaderActivity::loop() { return; } - // At end of the book, forward button goes home and back button returns to last page + // At end of the book with no suggestion menu, forward button goes home and back + // button returns to last page if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { + if (endOfBookOptions.menuActive()) { + // Selection movement was handled above; absorb leftover page-turn triggers so + // e.g. "previous" at the top of the list doesn't jump back into the book + return; + } if (nextTriggered) { onGoHome(); } else { @@ -865,8 +904,11 @@ void EpubReaderActivity::render(RenderLock&& lock) { // Show end of book screen if (currentSpineIndex == epub->getSpineItemsCount()) { + // Sole load site: runs on the render task (serialized by RenderLock); the main + // task only reads the suggestions once the loaded flag is published + endOfBookOptions.loadOnce(epub->getPath()); renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); + endOfBookOptions.render(renderer, mappedInput); renderer.displayBuffer(); automaticPageTurnActive = false; showPendingSyncSaveError(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 691a0b07db..503bb23e65 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -6,6 +6,7 @@ #include #include "BookmarkEntry.h" +#include "EndOfBookOptions.h" #include "EpubReaderMenuActivity.h" #include "ProgressMapper.h" #include "activities/Activity.h" @@ -45,6 +46,8 @@ class EpubReaderActivity final : public Activity { // Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on. // Consumed in onExit() to relocate the finished book into /Read/. bool pendingReadFolderMove = false; + // Next-book suggestion menu for the End-of-Book screen + EndOfBookOptions endOfBookOptions; // Footnote support std::vector currentPageFootnotes; @@ -88,6 +91,8 @@ class EpubReaderActivity final : public Activity { // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); + // Opens the reader menu for the current position (short-press Confirm) + void openReaderMenu(); // Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op // because no KOReader credentials are stored. bool launchKOReaderSync(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index d4af3e9b73..1d1923eb30 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -53,18 +53,52 @@ void XtcReaderActivity::onExit() { xtc.reset(); } +void XtcReaderActivity::openChapterSelection() { + if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) { + startActivityForResult(std::make_unique(renderer, mappedInput, xtc, currentPage), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + currentPage = std::get(result.data).page; + } + }); + } +} + void XtcReaderActivity::loop() { + if (!xtc) { + return; + } + + const bool atEndOfBook = currentPage >= xtc->getPageCount(); + + // While the end screen suggestion menu is showing it owns Confirm/Back/navigation + // input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls + // through to the regular handlers below; page turns are absorbed by the end-of-book + // block. + if (atEndOfBook && endOfBookOptions.menuActive()) { + std::string openPath; + switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) { + case EndOfBookOptions::Action::OpenBook: + activityManager.goToReader(openPath); + return; + case EndOfBookOptions::Action::GoHome: + onGoHome(); + return; + case EndOfBookOptions::Action::LastPage: + currentPage = xtc->getPageCount() > 0 ? xtc->getPageCount() - 1 : 0; + requestUpdate(); + return; + case EndOfBookOptions::Action::Redraw: + requestUpdate(); + return; + case EndOfBookOptions::Action::None: + break; + } + } + // Enter chapter selection activity if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) { - startActivityForResult( - std::make_unique(renderer, mappedInput, xtc, currentPage), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - currentPage = std::get(result.data).page; - } - }); - } + openChapterSelection(); } // Long press BACK (1s+) goes to file selection @@ -85,8 +119,14 @@ void XtcReaderActivity::loop() { return; } - // At end of the book, forward button goes home and back button returns to last page + // At end of the book with no suggestion menu, forward button goes home and back + // button returns to last page if (currentPage >= xtc->getPageCount()) { + if (endOfBookOptions.menuActive()) { + // Selection movement was handled above; absorb leftover page-turn triggers so + // e.g. "previous" at the top of the list doesn't jump back into the book + return; + } if (nextTriggered) { onGoHome(); } else { @@ -123,9 +163,11 @@ void XtcReaderActivity::render(RenderLock&&) { // Bounds check if (currentPage >= xtc->getPageCount()) { - // Show end of book screen + // Show end of book screen. Sole load site: runs on the render task (serialized by + // RenderLock); the main task only reads the suggestions once the flag is published. + endOfBookOptions.loadOnce(xtc->getPath()); renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); + endOfBookOptions.render(renderer, mappedInput); renderer.displayBuffer(); return; } diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index f020b0b04a..872b20c79f 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -12,6 +12,7 @@ #include #include +#include "EndOfBookOptions.h" #include "activities/Activity.h" class XtcReaderActivity final : public Activity { @@ -19,6 +20,8 @@ class XtcReaderActivity final : public Activity { uint32_t currentPage = 0; int pagesUntilFullRefresh = 0; + // Next-book suggestion menu for the End-of-Book screen + EndOfBookOptions endOfBookOptions; enum class StatusBarOverlayPosition { Bottom, Top }; struct StatusBarInfo { @@ -28,6 +31,8 @@ class XtcReaderActivity final : public Activity { }; void renderPage(); + // Opens chapter selection when the book has chapters (short-press Confirm); no-op otherwise + void openChapterSelection(); void renderStatusBarOverlay(StatusBarOverlayPosition position) const; StatusBarInfo getStatusBarInfo() const; void saveProgress() const; diff --git a/src/util/NextBookFinder.cpp b/src/util/NextBookFinder.cpp new file mode 100644 index 0000000000..63fe3f8596 --- /dev/null +++ b/src/util/NextBookFinder.cpp @@ -0,0 +1,85 @@ +#include "NextBookFinder.h" + +#include +#include +#include +#include + +#include +#include + +#include "CrossPointSettings.h" + +namespace { +constexpr size_t NAME_BUFFER_SIZE = 500; + +bool isSupportedBookFile(const std::string_view name) { + // Formats ReaderActivity can open (bmp is a viewer, not a book, so it is excluded) + return FsHelpers::hasEpubExtension(name) || FsHelpers::hasXtcExtension(name) || FsHelpers::hasTxtExtension(name) || + FsHelpers::hasMarkdownExtension(name); +} +} // namespace + +std::vector NextBookFinder::findNextBooks(const std::string& currentBookPath, const size_t maxCount) { + std::vector result; + if (maxCount == 0 || currentBookPath.empty()) { + return result; + } + + const std::string folder = FsHelpers::extractFolderPath(currentBookPath); + const auto lastSlash = currentBookPath.find_last_of('/'); + const std::string currentName = + lastSlash == std::string::npos ? currentBookPath : currentBookPath.substr(lastSlash + 1); + + auto dir = Storage.open(folder.c_str()); + if (!dir || !dir.isDirectory()) { + LOG_ERR("NBF", "Cannot open folder: %s", folder.c_str()); + return result; + } + dir.rewindDirectory(); + + const auto nameBuffer = makeUniqueNoThrow(NAME_BUFFER_SIZE); + if (!nameBuffer) { + LOG_ERR("NBF", "OOM: %d bytes", static_cast(NAME_BUFFER_SIZE)); + dir.close(); + return result; + } + + // Heap use is bounded: at most maxCount+1 short filename strings live at once (the + // file browser holds a whole folder in the same std::string form). A failed + // allocation here would abort like any STL growth in this codebase; the reserve + // below makes vector growth a single up-front allocation. + result.reserve(maxCount + 1); + const auto less = [](const std::string& a, const std::string& b) { return FsHelpers::naturalLess(a, b); }; + + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (file.isDirectory()) { + continue; + } + file.getName(nameBuffer.get(), NAME_BUFFER_SIZE); + if (!SETTINGS.showHiddenFiles && nameBuffer[0] == '.') { + continue; + } + if (!isSupportedBookFile(nameBuffer.get())) { + continue; + } + std::string name{nameBuffer.get()}; + // Keep only files ordering strictly after the current one; equal names (the book + // itself, or a case-variant of it) compare "not less" both ways and drop out here. + if (!FsHelpers::naturalLess(currentName, name)) { + continue; + } + // Bounded insertion sort: keep the maxCount lowest-ordering candidates + if (result.size() >= maxCount && !less(name, result.back())) { + continue; + } + const auto pos = std::lower_bound(result.begin(), result.end(), name, less); + result.insert(pos, std::move(name)); + if (result.size() > maxCount) { + result.pop_back(); + } + } + dir.close(); + + return result; +} diff --git a/src/util/NextBookFinder.h b/src/util/NextBookFinder.h new file mode 100644 index 0000000000..1f6bb0fd48 --- /dev/null +++ b/src/util/NextBookFinder.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace NextBookFinder { + +// Collects up to maxCount book files that order after currentBookPath's filename +// (natural sort, same ordering as the file browser) within the same folder. +// Returns bare filenames in sorted order; the current file itself is excluded. +// Single directory pass keeping only the maxCount best matches, so memory stays +// bounded regardless of folder size. +std::vector findNextBooks(const std::string& currentBookPath, size_t maxCount); + +} // namespace NextBookFinder From ca1b833126c37521a09d92dbd4de928380705b99 Mon Sep 17 00:00:00 2001 From: Jacob Latonis Date: Sun, 5 Jul 2026 17:33:34 -0400 Subject: [PATCH 04/68] fix: follow spec for zxing qr code generation (#2540) --- src/activities/network/CrossPointWebServerActivity.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 29737a5a03..8be6d1e0c3 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -410,7 +410,8 @@ void CrossPointWebServerActivity::renderServerRunning() const { startY += height10 + metrics.verticalSpacing * 2; // Show QR code for Wifi - const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; + // follows spec at https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11 + const std::string wifiConfig = std::string("WIFI:T:nopass;S:") + connectedSSID + ";;"; const Rect qrBoundsWifi(metrics.contentSidePadding, startY, QR_CODE_WIDTH, QR_CODE_HEIGHT); QrUtils::drawQrCode(renderer, qrBoundsWifi, wifiConfig); From 44ff313740b37bcc98376c2bb4a412cca2a5260c Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 6 Jul 2026 20:10:31 +0300 Subject: [PATCH 05/68] fix: render
    between paragraphs as a visible section break (#2548) Co-authored-by: Brooks Ilg --- lib/Epub/Epub/blocks/BlockStyle.h | 8 + .../Epub/parsers/ChapterHtmlSlimParser.cpp | 20 +- scripts/generate_br_section_break_epub.py | 210 ++++++++++++++++++ test/epubs/test_br_section_break.epub | Bin 0 -> 4568 bytes 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 scripts/generate_br_section_break_epub.py create mode 100644 test/epubs/test_br_section_break.epub diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index fbc18d4248..08bcfd627c 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -32,6 +32,11 @@ struct BlockStyle { bool isRtl = false; // true if resolved direction is RTL bool directionDefined = false; // true if direction was explicitly set in CSS/HTML + // Set when this block was created by a
    element. Used by startNewTextBlock to inject + // a full line-height gap when the
    block stays empty (section-break use case). + // NOT propagated through getCombinedBlockStyle so it can't leak into sibling blocks. + bool fromBrElement = false; + // Combined insets (margin + padding) [[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; } [[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; } @@ -92,6 +97,9 @@ struct BlockStyle { result.directionDefined = true; } + // fromBrElement is consumed by startNewTextBlock when an empty
    block + // is merged with the following paragraph; never propagate it further. + result.fromBrElement = false; return result; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b04159e7ec..7263de4af7 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -239,7 +239,16 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { // open. Merge those into the new style so the first child in a container inherits // the container's vertical spacing. const auto style = currentTextBlock->getBlockStyle(); - currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical)); + BlockStyle incoming = blockStyle; + if (style.fromBrElement) { + // The empty block was created by a
    section separator. Inject a full line of + // blank space before the following paragraph so the scene/section break is visible. + // This only fires when the
    block stayed empty (i.e. no inline text was added). + const int16_t lineHeight = static_cast(renderer.getLineHeight(fontId) * lineCompression + 0.5f); + incoming.marginTop = static_cast(incoming.marginTop + lineHeight); + } + + currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(incoming, BlockStyle::CombineAxis::Vertical)); flushPendingAnchor(); return; @@ -855,7 +864,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // flush word preceding
    to currentTextBlock before calling startNewTextBlock self->flushPartWordBuffer(); } - self->startNewTextBlock(self->blockStyleStack.back().withoutBottom()); + // Tag the new block so startNewTextBlock can inject a full line-height gap if + // the block remains empty (i.e.
    is a section separator between paragraphs). + // If the block gets text added before the next block opens it becomes non-empty, + // goes through makePages() normally, and the flag has no effect (inline
    case). + BlockStyle brStyle = + self->currentTextBlock ? self->currentTextBlock->getBlockStyle() : self->blockStyleStack.back(); + brStyle.fromBrElement = true; + self->startNewTextBlock(brStyle); } else { self->currentCssStyle = cssStyle; const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle, diff --git a/scripts/generate_br_section_break_epub.py b/scripts/generate_br_section_break_epub.py new file mode 100644 index 0000000000..a22d44625c --- /dev/null +++ b/scripts/generate_br_section_break_epub.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Generate a test EPUB for
    section-break rendering. + +Tests that a bare
    element between paragraphs produces a visible blank-line +gap (section separator), while a
    inside a paragraph only produces a line +break with no extra spacing. + +Cases covered: + 1. Standalone
    between paragraphs (section break — must show gap). + 2.
    with a CSS class (calibre-style section break). + 3. Multiple consecutive
    elements (each adds one line of spacing). + 4. Inline
    inside a

    (line break only — no extra gap). + 5.
    at start of chapter (no gap before first paragraph). + 6.
    following a heading. + +Visual verification instructions are embedded as the first paragraph of each +chapter so a human tester can confirm the expected result on device. +""" + +import os +import zipfile +from pathlib import Path + +OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs" +OUTPUT_PATH = OUTPUT_DIR / "test_br_section_break.epub" + +FILLER = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " + "tempor incididunt ut labore et dolore magna aliqua." +) + +CSS = """\ +body { margin: 0; padding: 0; } +p { margin-top: 1pt; margin-bottom: 0; text-indent: 1em; text-align: justify; } +h1 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; } +h2 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; } +.section-br { display: block; } +""" + +def xhtml(title, body): + return f"""\ + + + + + {title} + + + +{body} + +""" + + +# --------------------------------------------------------------------------- +# Chapter 1 — standalone
    between paragraphs +# --------------------------------------------------------------------------- +ch1 = xhtml("Ch1: Standalone br", f""" +

    Ch 1: Standalone <br> Section Break

    +

    PASS: A visible blank-line gap should appear between the two sections below.

    +

    {FILLER}

    +
    +

    {FILLER}

    +

    PASS: The gap above should be roughly one line tall (same as a blank line).

    +""") + +# --------------------------------------------------------------------------- +# Chapter 2 —
    CSS-classed section break (calibre style) +# --------------------------------------------------------------------------- +ch2 = xhtml("Ch2: Classed br", f""" +

    Ch 2: <br class="section-br"/>

    +

    PASS: A blank-line gap should appear between the two sections below, identical +to Ch 1, even though the <br> carries a CSS class.

    +

    {FILLER}

    +
    +

    {FILLER}

    +""") + +# --------------------------------------------------------------------------- +# Chapter 3 — multiple consecutive
    elements +# --------------------------------------------------------------------------- +ch3 = xhtml("Ch3: Multiple br", f""" +

    Ch 3: Multiple Consecutive <br> Elements

    +

    PASS: Two blank lines should appear between the sections (one per <br>).

    +

    {FILLER}

    +
    +
    +

    {FILLER}

    +

    PASS: Three blank lines should appear below.

    +

    {FILLER}

    +
    +
    +
    +

    {FILLER}

    +""") + +# --------------------------------------------------------------------------- +# Chapter 4 — inline
    inside a paragraph (line break, NOT a gap) +# --------------------------------------------------------------------------- +ch4 = xhtml("Ch4: Inline br", """ +

    Ch 4: Inline <br> Inside a Paragraph

    +

    PASS: The two lines below should be adjacent with NO extra gap between them. +The <br> is inside the paragraph and must only break the line.

    +

    First line of the paragraph.
    Second line of the paragraph — directly below, no gap.

    +

    PASS: Above should look like two closely-spaced lines, not like two paragraphs +separated by a blank line.

    +""") + +# --------------------------------------------------------------------------- +# Chapter 5 —
    following a heading +# --------------------------------------------------------------------------- +ch5 = xhtml("Ch5: br after heading", f""" +

    Ch 5: <br> After a Heading

    +
    +

    PASS: There should be a blank-line gap between the heading above and this paragraph.

    +

    {FILLER}

    +

    Section heading

    +
    +

    PASS: There should be a blank-line gap between the section heading and this paragraph.

    +""") + +# --------------------------------------------------------------------------- +# Chapter 6 —
    at very start of chapter (no spurious leading gap) +# --------------------------------------------------------------------------- +ch6 = xhtml("Ch6: br at chapter start", f"""
    +

    Ch 6: <br> at Chapter Start

    +

    PASS: This heading should appear near the top of the page with no large blank +area above it despite the <br> being the very first element.

    +

    {FILLER}

    +""") + +CHAPTERS = [ + ("ch1", "chapter1.xhtml", "Chapter 1: Standalone br", ch1), + ("ch2", "chapter2.xhtml", "Chapter 2: Classed br", ch2), + ("ch3", "chapter3.xhtml", "Chapter 3: Multiple br", ch3), + ("ch4", "chapter4.xhtml", "Chapter 4: Inline br", ch4), + ("ch5", "chapter5.xhtml", "Chapter 5: br after heading", ch5), + ("ch6", "chapter6.xhtml", "Chapter 6: br at start", ch6), +] + +def build_epub(path): + os.makedirs(os.path.dirname(path), exist_ok=True) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as epub: + # mimetype must be first and uncompressed + epub.writestr("mimetype", "application/epub+zip", + compress_type=zipfile.ZIP_STORED) + + epub.writestr("META-INF/container.xml", """\ + + + + + +""") + + epub.writestr("OEBPS/styles/test.css", CSS) + + manifest_items = [] + spine_items = [] + nav_items = [] + + for (chid, chfile, chtitle, chcontent) in CHAPTERS: + epub.writestr(f"OEBPS/{chfile}", chcontent) + manifest_items.append( + f' ') + spine_items.append(f' ') + nav_items.append(f'
  • {chtitle}
  • ') + + manifest_items.append( + ' ') + + content_opf = f"""\ + + + + test-epub-br-section-break + Test: br Section Break + en + + +{chr(10).join(manifest_items)} + + +{chr(10).join(spine_items)} + +""" + epub.writestr("OEBPS/content.opf", content_opf) + + nav_xhtml = f"""\ + + +Table of Contents + + + +""" + epub.writestr("OEBPS/nav.xhtml", nav_xhtml) + + print(f"Generated: {path}") + + +if __name__ == "__main__": + build_epub(OUTPUT_PATH) diff --git a/test/epubs/test_br_section_break.epub b/test/epubs/test_br_section_break.epub new file mode 100644 index 0000000000000000000000000000000000000000..5d7711662c2e76424a0d7be2f274cb83e223e8e7 GIT binary patch literal 4568 zcmZ{oc{EgiAIE3x3?XDovZRDD_FZ-*Cj0WUFO%I^Mn5AvgRI#yRMTP?vKLCol6~JZ zWGS*0F?P@N)RSXQ&z*D6o%_ewK2?)xAkf!U*_z^*lpbz-?a${(Sp+Q{ZoR zXLlF^=?Sy-^mKE!w?#O^J%nMNzIFos&YrrzBPsqHDbXfXQHGD81Ofo(@J}6eLuH{G zPz_;wxCg@4*#qVc_H%bLO6a6&g3zhsg4EH)mHm$+a>JgQlWq~)$rd*v_{0kqr`hYi ziPV7{*J_6M`tN$Ramn)0bb14c+P&xU2R#*6W0;DT&0rbHUJ>CR0oR3qZCOWd${C@84Y=qz0vkhdvE<7>FG@ZoW5+{!*7lgzey(i z^Om}bu7R)*0_g_x5k|m#5MX;BpW^tNXPO|?q_{^-%ud7HWjCNH;#@2n2KKz%%~M`P zX(19;X7ClHYzEc86fV9jq}pf8Mai<3_wCa|9p{L11Z{eFUnyOF&bTKu&Q8M;AKxq` z`G#SkzicN!D57m5HLphC+EUZ4J89MugMLoQ)6Y#h9vog1v@ZkM+Lf1g*|b|30Vnbn z26h-uul-HBN?0s@-6KT5@cvr6y_2mc0_H6O_H#nGyLId9Bo0F8-r`K;y-h=#Th%O_ zq}pA~W+Ixkgvb-1^1Q|za$_mEhdAxb)zIJue(%qLannA#eizO|8B=KLJ7Nx0$u`^} zBmWw0Z+=@nGT1hScSu%NtLz^5)d(XVLndcQy^t$70q?z`^M*>uemOPvo!C)!sngyH#kui6uw2)nC*QI zjAwbfB-9u&!MP5TP|4HGR@ClMO5|CJj`ZW(n87soDE)IOh1vqWsUL6Bz0NfNkjr%r zp#-b(cJ>i}zgtPfeC|8rvv>}7mrKF-D@3&&A^_zEY+=Ab2-W!B2%9TkFPa4^#Z5bF zlnGhvadmP$*530)@f9B1Cz;nr1;ra&xm=5yptD2QVl7JzeY+o3`S?9_P+uf@JL1Lh z*8Eb=b`{*&6Zs?JiPY5!4v%rFIm|K4-d7z%S6lJuO&#toIJ6HAbNy; zCdN;mP+m2b*^!(y;=8eK*0#_}JCmKX{q_)3d6g3!-X@T7;fvHTnvhenbA4FLpT^-w<-IMFXBnc* zon_~et@L0Ft5sMEq_8-}TF+)+N*~y;Ouy56$}%OjetE|#;I3Ev*5KT3Kr;^;yr~m9 zKqo84jq}<#cl&%huXwYV9soyz;lb9z_;;k?p9D$o6n|$=pgRZx`M+J78`_eZWOG!` zlHix=fJi^?b!~V)G$+*a3hflazA4_I!O2i=j}G;VHI63Y!<>!t&FU}vn4Vrm zKc6eF2FkT%tN;~P^gu&JMBX@OD@2cuL^L8A8~kv0Md9>EhYrW;lC0aZFjnlwrDMQp zYPJI&NPj%F3F5z`oY-$_28{`W5XLt+0WwbGi^|$n%o950DlnN)Q|HdmEQV`rpf<_v z&fLShR@J2w3}39=c9GaFrvr=W3z~_V9W5<=`Mz6Lp(>okpeT*N>EIxo@>KPUz2Q7~ z-C^Rs9JRc^j4*q)M5+c&m-CA)`QG6G%a(w?Ai69LjQ_*TmpoHA^&|2iOP(2i5 zxe);#-LtT!!=kJ|%(ENq%okAOx#g+F+sT%%-PB_dJv@l&(?-3>DN@&ZQuP%)VNiE= zsp`Y094t6e<*<1z$#Y>hx6_2YzVUHh5)rel>CIr5^=pdqZe>q+WZ})J(ph1m&F$vs z)DI4bpX6vsxM256XMCw6)OJbt9~O=*-P^j^CEk=H8eiJq7B|asn>Q%-sGXJJ7Hezm zyq0hxU;_#UnK=V$3gY_1oV5p5RMv|s3~aQi;j+)jzh`7ZGF2fE>>;Mh8e}=ryG%NU z>@mZn9zCo~+=S)&@tbvgdy;3R;kTO=Ou(w?Skfp7¬B87BUVm3wld$0US0ay^I& z8ig@_(RjyHe^R-xqyg+2k{!hXdst;Ckjz!)8!)N5LW$$evddl#!B8h(+x?zrQItpy z5@vb+y@WCLKnqaY`l{78$8T)fGKgXMs(|7Yc6ue){atu&_=0p`h`FU8ijU1$KVc4? zXzk4@TVo1N-$09VJL^>$k?G#&iXDzWstx1pVT;m@Qt25t{9;H8bm!;RIhUou3lyg@ zrE217=hFbn&dix{QSvPC3$gM#>g(;_DHJ6WW+8)Df^LrbzFu6+6zi{~psK!_56)Ni z!G+z8a;Y0%H7*^c5cj_xPw!G06p?>e;VuIhRxBXnXmq;%zW46TYFHSQ#v+HlALuMU zZ^pKH#+4hL(FJ4aanmh9z4zh$)&mV-GCaJIw20)28npYDuXJ|4`P$L7i^5+ozTl9= zkO$_6ynA=7ZBzSQ0(65=7P_j>7W3 zY(T4P1ItP=I|EflCBa{!r(J`t)CcSp91#@BI~b8Get3kze?uto3t>*O@~w|Qi{w5& z-=x#f?nd`4lP{_%D;N?vrxR;lzF_w=-y&9y?zXy_7|}kGR?k4L2a073nbh?+vgf*m z!<)-xnM3MnC-$DvCr;K?fV_smUsJ4cQj>X6<`8k&AYodY7rpTiH7}tBe7YTEQ90l5 z9Co?%uR2f6xik-EfP|U%7#97+b@6PzV-rbEd(l4^GSxp91&2nI&}VF)sZizxRwj;7 zG;y>mIs*AVZR&ARiW%hyW?ZIis0H~?gVFCC_#i)q`eEr%^4f@H+Sg&zn)0z@Nio-| zX-6M+vsIHZs7Ts+v&lO6en}0MncJl!W2(^*drmIu_mS9;8Eg7g!o)o?Kbb8#CbtlF z@ZpHzY=VtFMx(D0MKXRipMHt`N2B8krSt4~WxOQr62s$#+Zx?uN^%P#p7IGv-6J?- za|LABy#^7CqlT;+Pn^p)s?>^?DSL8ZyI2pFCfpJ3d(C#v>gI9ileOM984Fa>f~G(D z>B4wL`t`S9JSpYBk&^sHN=LsFpKNq*_64ft=Z9h>h)Akg8 zjZ00`D8pQ1Fpg=-%?}k4P)fIk$2= zY9zZet3q4g=cjPHx3l+M?2c_`OVKt%g*tWVZSQSCn)V<=Z~}46y_CVH`YLH$LnvyZ z7LxA>1wIMxt_Rc+6i`@+e_qqF7R>PJ-cEuIrT7B}%9lLH$fdHvA-;EQq) z6FzKwE7XR&`lFb_MWG=ItN74(xqZok79z)e`ocFssNmDUZmDJpy5fP|CIJBG{|vrA zM|+qD0u1-Oos+8L5suF*oD6Ego%GE8Y$hUZ-+qv|LWn)B4QZUKb~3j9^JU&=DuvA+ zMN;bzr5-Fe1q*=?WR|g7tz$l;ETIle%%ZX1M?bwszTbCX;_rZp6FbzwIOaaGFrD?T zv9M?~`f4o8bL4-hbv12P4(#L=&p1sZ9TqOm&Ggj%BSk_AlZqZ?q_>MxFO3X)e__9R z)IyvRFh^pv*+*f3wK%QfTY_2`ts!xhmy(3$m;A?U=rUzD${&nLmpeJ^Bls`MnA%4t zumx`O{ZP!@i!6KhRMd3^#(Pl?DgTN+X;SMj$S`IojJyJ(dq>Y~%{JBb`egD|;t8LZ zHT{&zj0{1^OtP%6L`T-4-kZU$FC0#Ao5>qzwO*MptL_Nya)&Qo7egS3m5# z19D2nz{SmvI3rR^7@c^Yu5mf|U8jX#E=;@s4bq=brW&<~H9%i(f07=CbQTkK|B^E$ zv`Z(+{r+Xr0nuqPfg>U}F8uCWw13EW*xvb-Me#{G9`7&WxhS?o2kJa}H$stu%F(h! z4F3Q~w~CwvI`DaU@6VRTFsJ=X`?*_+IFWOWnOI7KWYtYQCO}vy!@P1rH|avZm{yxtSDVgSq?k}gc{Z+HHTfQYu)Ecx+O4U;AKk_5FZ^^~TK7MBNEqtJQK>Ec~R_Qb^i*b zcvKGX{R2qENc{ipFaA9LdxhW+_{0pKME%|R{)zzrLV&XPzkfHvgc^j-@ Date: Mon, 6 Jul 2026 23:13:27 +0300 Subject: [PATCH 06/68] chore: Replace product link with affiliate tracking link (#2401) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c938b6df41..ddf70792f2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f ![CrossPoint Reader running on Xteink device](./docs/images/cover.jpg) +> If you're planning to buy an Xteink device, consider purchasing an **X3/X4 Developer Edition** through https://crosspointreader.com. CrossPoint receives a small share of each sale, helping fund development costs. + ## What can CrossPoint do? - **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more. From 6f5c5a0900ec10e4fa7bbf69a18c4e63183cb9a0 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 6 Jul 2026 16:41:00 -0400 Subject: [PATCH 07/68] fix: Flatten TextBlock word storage into single allocation (#2547) --- docs/file-formats.md | 30 ++-- lib/Epub/Epub/Page.cpp | 18 +- lib/Epub/Epub/ParsedText.cpp | 19 ++- lib/Epub/Epub/Section.cpp | 17 +- lib/Epub/Epub/blocks/TextBlock.cpp | 253 +++++++++++++++++++++-------- lib/Epub/Epub/blocks/TextBlock.h | 97 +++++++---- 6 files changed, 314 insertions(+), 120 deletions(-) diff --git a/docs/file-formats.md b/docs/file-formats.md index eec9474f8c..4289f7e18e 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -90,13 +90,13 @@ if (parsedSize != fileSize) { ## `section.bin` -### Version 28 +### Version 29 Each file in `sections/*.bin` stores one laid-out spine section. The header is also the cache-busting key: if any layout-affecting setting differs from the current reader settings, the section is discarded and rebuilt. -Version 28 includes: +Version 29 includes: - cache-busting fields for paragraph alignment, hyphenation, embedded CSS, image rendering mode, and Focus Reading @@ -107,6 +107,10 @@ Version 28 includes: - per-page footnote entries - serialized word style bits for underline, strikethrough, superscript, and subscript +- flat TextBlock word storage (v29): per-word arrays plus one shared + NUL-terminated text blob, replacing v28's length-prefixed word strings. The + on-disk order mirrors the in-RAM arena so the firmware reads a whole block + payload with a single allocation and a single SD read ImHex pattern: @@ -115,7 +119,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 28 +#define EXPECTED_VERSION 29 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 96 @@ -176,14 +180,20 @@ struct BlockStyle { struct TextBlock { u16 wordCount; - String words[wordCount]; - s16 wordXPos[wordCount]; - WordStyle wordStyle[wordCount]; - u8 hasFocus; - if (hasFocus != 0) { - u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; - u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; + u16 textBytes [[comment("Total size of text[], including one NUL per word")]]; + + if (wordCount > 0) { + u16 textOff[wordCount] [[comment("Byte offset of word i's text within text[]")]]; + s16 wordXPos[wordCount]; + if (hasFocus != 0) { + u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; + } + WordStyle wordStyle[wordCount]; + if (hasFocus != 0) { + u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; + } + char text[textBytes] [[comment("All words back to back, each NUL-terminated")]]; } BlockStyle blockStyle; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 5032056cd5..25c1f512fa 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -39,7 +39,17 @@ std::unique_ptr PageLine::deserialize(HalFile& file) { serialization::readPod(file, yPos); auto tb = TextBlock::deserialize(file); - return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); + if (!tb) { + LOG_ERR("PGE", "Deserialization failed: null TextBlock"); + return nullptr; + } + + auto* line = new (std::nothrow) PageLine(std::move(tb), xPos, yPos); + if (!line) { + LOG_ERR("PGE", "Deserialization failed: could not allocate PageLine"); + return nullptr; + } + return std::unique_ptr(line); } void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { @@ -155,9 +165,15 @@ std::unique_ptr Page::deserialize(HalFile& file) { if (tag == TAG_PageLine) { auto pl = PageLine::deserialize(file); + if (!pl) { + return nullptr; + } page->elements.push_back(std::move(pl)); } else if (tag == TAG_PageImage) { auto pi = PageImage::deserialize(file); + if (!pi) { + return nullptr; + } page->elements.push_back(std::move(pi)); } else if (tag == TAG_PageHorizontalRule) { auto rule = PageHorizontalRule::deserialize(file); diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index ebe758db4f..2ae204a415 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -1133,8 +1134,14 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } if (!lineHasFocusSplit) { - processLine(std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), - std::vector{}, std::vector{}, blockStyle)); + // TextBlock flattens the vectors into its arena; they stay owned here and die at return. + auto block = std::make_shared(lineWords, lineXPos, lineWordStyles, std::vector{}, + std::vector{}, blockStyle); + if (!block->valid()) { + LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); + return; + } + processLine(std::move(block)); return; } @@ -1179,6 +1186,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } } - processLine(std::make_shared(std::move(outWords), std::move(outXPos), std::move(outStyles), - std::move(outBoundaries), std::move(outSuffixX), blockStyle)); + auto block = std::make_shared(outWords, outXPos, outStyles, outBoundaries, outSuffixX, blockStyle); + if (!block->valid()) { + LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); + return; + } + processLine(std::move(block)); } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 2de11f58d4..663d086b10 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -11,8 +11,9 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -// v28: text decoration bits now include line-through in serialized wordStyles. -constexpr uint8_t SECTION_FILE_VERSION = 28; +// v29: TextBlock word data stored as one flat arena (offset table + NUL-terminated +// text blob) instead of length-prefixed strings and per-field arrays. +constexpr uint8_t SECTION_FILE_VERSION = 29; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects @@ -25,7 +26,11 @@ constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0; // rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION, // so finalized files are untouched by this feature; older firmware treats the sentinel // as an unknown version and rebuilds, which is a safe downgrade. -constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE; +// MUST change in lockstep with SECTION_FILE_VERSION: the sentinel IS the partial's +// format version, so a stale-format partial otherwise passes the header check and +// only fails (noisily, via the block-decode error path) when a page is loaded. +// Derived so the pairing can't be forgotten: 0xFE for v28, 0xFD for v29, ... +constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE - (SECTION_FILE_VERSION - 28); constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + @@ -733,10 +738,10 @@ std::string Section::getTextFromSectionFile() { if (el->getTag() == TAG_PageLine) { const auto& line = static_cast(*el); if (line.getBlock()) { - const auto& words = line.getBlock()->getWords(); - for (const auto& w : words) { + const auto& block = *line.getBlock(); + for (uint16_t i = 0; i < block.wordCount(); i++) { if (!fullText.empty()) fullText += " "; - fullText += w; + fullText += block.wordText(i); } } } diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index 3d5f920b49..c90467d6ed 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -3,19 +3,114 @@ #include #include #include +#include #include #include -void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { +size_t TextBlock::arenaSize(const uint16_t wordCount, const bool hasFocus, const uint16_t textBytes) { + // Layout documented in TextBlock.h: 16-bit arrays first, then 8-bit arrays, then text. + size_t size = static_cast(wordCount) * (sizeof(uint16_t) + sizeof(int16_t) + sizeof(uint8_t)); + if (hasFocus) { + size += static_cast(wordCount) * (sizeof(uint16_t) + sizeof(uint8_t)); + } + return size + textBytes; +} + +void TextBlock::bindArenaPointers() { + uint8_t* base = arena.get(); + const size_t wc = numWords; + textOffArr = reinterpret_cast(base); + xposArr = reinterpret_cast(base + wc * 2); + size_t off = wc * 4; + if (focusPresent) { + focusSuffixXArr = reinterpret_cast(base + off); + off += wc * 2; + } + stylesArr = base + off; + off += wc; + if (focusPresent) { + focusBoundaryArr = base + off; + off += wc; + } + textArr = reinterpret_cast(base + off); +} + +TextBlock::TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& focusBoundary, + const std::vector& focusSuffixX, const BlockStyle& blockStyle) + : blockStyle(blockStyle) { // Focus annotations are optional: empty vectors mean no word in this block has a split. // When present, they must be sized in lockstep with words[]. - const bool hasFocus = !wordFocusBoundary.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { - LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", - (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(), - (uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size()); + const bool hasFocus = !focusBoundary.empty(); + if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || words.size() > 10000 || + (hasFocus && (words.size() != focusBoundary.size() || words.size() != focusSuffixX.size()))) { + LOG_ERR("TXB", "Construction failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)", + static_cast(words.size()), static_cast(wordXpos.size()), + static_cast(wordStyles.size()), static_cast(focusBoundary.size()), + static_cast(focusSuffixX.size())); + isValid = false; + return; + } + + numWords = static_cast(words.size()); + focusPresent = hasFocus; + if (numWords == 0) { + return; // valid empty block, no arena + } + + // Pass 1: total text size, one NUL per word. A line is at most a physical + // row of the page, so uint16_t offsets are ample; reject anything larger. + size_t totalText = 0; + for (const auto& w : words) totalText += w.size() + 1; + if (totalText > UINT16_MAX) { + LOG_ERR("TXB", "Construction failed: text size %u exceeds arena limit", static_cast(totalText)); + numWords = 0; + focusPresent = false; + isValid = false; + return; + } + textBytes = static_cast(totalText); + + const size_t size = arenaSize(numWords, focusPresent, textBytes); + arena = makeUniqueNoThrow(size); + if (!arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + numWords = 0; + textBytes = 0; + focusPresent = false; + isValid = false; + return; + } + bindArenaPointers(); + + // Pass 2: fill. Mutable aliases of the const views bound above. + auto* textOff = const_cast(textOffArr); + auto* xpos = const_cast(xposArr); + auto* styles = const_cast(stylesArr); + auto* text = const_cast(textArr); + uint16_t off = 0; + for (uint16_t i = 0; i < numWords; i++) { + textOff[i] = off; + xpos[i] = wordXpos[i]; + styles[i] = static_cast(wordStyles[i]); + memcpy(text + off, words[i].data(), words[i].size()); + off += static_cast(words[i].size()); + text[off++] = '\0'; + } + if (focusPresent) { + auto* suffixX = const_cast(focusSuffixXArr); + auto* boundary = const_cast(focusBoundaryArr); + for (uint16_t i = 0; i < numWords; i++) { + suffixX[i] = focusSuffixX[i]; + boundary[i] = focusBoundary[i]; + } + } +} + +void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { + if (!isValid) { + LOG_ERR("TXB", "Render skipped: invalid block"); return; } @@ -54,12 +149,13 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } }; - for (size_t i = 0; i < words.size(); i++) { - const int wordX = wordXpos[i] + x; - const EpdFontFamily::Style currentStyle = wordStyles[i]; - const auto baseDir = static_cast( - BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0)); - const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0; + for (uint16_t i = 0; i < numWords; i++) { + const char* word = wordText(i); + const int wordX = xposArr[i] + x; + const EpdFontFamily::Style currentStyle = wordStyle(i); + const auto baseDir = + static_cast(BidiUtils::detectParagraphLevel(word, blockStyle.isRtl ? 1 : 0)); + const uint8_t boundary = focusBoundary(i); // SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside // drawText, so these offsets are chosen relative to the full-size ascender: @@ -82,14 +178,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES, "boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)"); const auto boldStyle = static_cast(currentStyle | EpdFontFamily::BOLD); - const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1}); - memcpy(boldBuf, words[i].c_str(), boldLen); + const size_t boldLen = + std::min({static_cast(boundary), static_cast(wordTextLen(i)), sizeof(boldBuf) - 1}); + memcpy(boldBuf, word, boldLen); boldBuf[boldLen] = '\0'; renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir); - const int suffixX = wordX + wordFocusSuffixX[i]; - renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir); + const int suffixX = wordX + focusSuffixXArr[i]; + renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir); } else { - renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir); + renderer.drawText(fontId, wordX, wordY, word, true, currentStyle, baseDir); } if (scanning) { @@ -97,18 +194,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } if (EpdFontFamily::hasTextDecoration(currentStyle)) { - const std::string& w = words[i]; int lineStartX = wordX; - int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir); + int lineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir); if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { lineWidth = (lineWidth + 1) / 2; } // Do not decorate the synthetic em-space used for paragraph indentation. - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visibleText = w.c_str() + 3; + if (wordTextLen(i) >= 3 && static_cast(word[0]) == 0xE2 && static_cast(word[1]) == 0x80 && + static_cast(word[2]) == 0x83) { + const char* visibleText = word + 3; lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir); if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { @@ -140,29 +236,23 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } bool TextBlock::serialize(HalFile& file) const { - // Focus annotations are optional; vectors are either empty (no splits in this block) - // or sized in lockstep with words[]. - const bool hasFocus = !wordFocusBoundary.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { - LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", - static_cast(words.size()), static_cast(wordXpos.size()), - static_cast(wordStyles.size()), static_cast(wordFocusBoundary.size()), - static_cast(wordFocusSuffixX.size())); + if (!isValid) { + LOG_ERR("TXB", "Serialization failed: invalid block"); return false; } - // Word data - serialization::writePod(file, static_cast(words.size())); - for (const auto& w : words) serialization::writeString(file, w); - for (auto x : wordXpos) serialization::writePod(file, x); - for (auto s : wordStyles) serialization::writePod(file, s); - // Focus block: 1-byte presence flag, followed by per-word vectors only when present. - // Saves 3 bytes/word when focus reading is disabled or no word on this line was split. - serialization::writePod(file, static_cast(hasFocus ? 1 : 0)); - if (hasFocus) { - for (auto b : wordFocusBoundary) serialization::writePod(file, b); - for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx); + // Word data: scalars, then the arena verbatim -- its in-memory layout is + // exactly the on-disk layout (see TextBlock.h), so one write covers all + // per-word arrays and the text blob. + serialization::writePod(file, numWords); + serialization::writePod(file, static_cast(focusPresent ? 1 : 0)); + serialization::writePod(file, textBytes); + if (numWords > 0) { + const size_t size = arenaSize(numWords, focusPresent, textBytes); + if (file.write(arena.get(), size) != size) { + LOG_ERR("TXB", "Serialization failed: arena write (%u bytes)", static_cast(size)); + return false; + } } // Style (alignment + margins/padding/indent) @@ -186,41 +276,64 @@ bool TextBlock::serialize(HalFile& file) const { std::unique_ptr TextBlock::deserialize(HalFile& file) { uint16_t wc; - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - std::vector wordFocusBoundary; - std::vector wordFocusSuffixX; - BlockStyle blockStyle; - - // Word count + uint8_t hasFocus; + uint16_t textBytes; serialization::readPod(file, wc); + serialization::readPod(file, hasFocus); + serialization::readPod(file, textBytes); - // Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block) + // Sanity checks: cap the arena allocation and reject impossible geometry + // (every word carries at least its NUL terminator). if (wc > 10000) { LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc); return nullptr; } + if ((wc == 0 && textBytes != 0) || (wc > 0 && textBytes < wc)) { + LOG_ERR("TXB", "Deserialization failed: bad text size %u for %u words", textBytes, wc); + return nullptr; + } - // Word data - words.resize(wc); - wordXpos.resize(wc); - wordStyles.resize(wc); - for (auto& w : words) serialization::readString(file, w); - for (auto& x : wordXpos) serialization::readPod(file, x); - for (auto& s : wordStyles) serialization::readPod(file, s); - // Focus block: presence flag, then vectors only if present. Empty vectors when absent - // signal "no splits in this block" to render() (zero per-word RAM cost). - uint8_t hasFocus; - serialization::readPod(file, hasFocus); - if (hasFocus) { - wordFocusBoundary.resize(wc); - wordFocusSuffixX.resize(wc); - for (auto& b : wordFocusBoundary) serialization::readPod(file, b); - for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx); + std::unique_ptr block(new (std::nothrow) TextBlock()); + if (!block) { + LOG_ERR("TXB", "OOM: TextBlock"); + return nullptr; + } + block->numWords = wc; + block->textBytes = textBytes; + block->focusPresent = hasFocus != 0; + + if (wc > 0) { + const size_t size = arenaSize(wc, block->focusPresent, textBytes); + block->arena = makeUniqueNoThrow(size); + if (!block->arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + return nullptr; + } + if (file.read(block->arena.get(), size) != size) { + LOG_ERR("TXB", "Deserialization failed: arena read (%u bytes)", static_cast(size)); + return nullptr; + } + block->bindArenaPointers(); + + // Validate offsets before anything dereferences wordText(): offset 0 first, + // strictly increasing, in bounds, and every word NUL-terminated (word i ends + // at the byte before offset i+1; the last word at the last text byte). + const uint16_t* textOff = block->textOffArr; + const char* text = block->textArr; + if (textOff[0] != 0 || text[textBytes - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt text layout"); + return nullptr; + } + for (uint16_t i = 1; i < wc; i++) { + if (textOff[i] <= textOff[i - 1] || textOff[i] >= textBytes || text[textOff[i] - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt word offset %u", i); + return nullptr; + } + } } // Style (alignment + margins/padding/indent) + BlockStyle& blockStyle = block->blockStyle; serialization::readPod(file, blockStyle.alignment); serialization::readPod(file, blockStyle.textAlignDefined); serialization::readPod(file, blockStyle.marginTop); @@ -236,7 +349,5 @@ std::unique_ptr TextBlock::deserialize(HalFile& file) { serialization::readPod(file, blockStyle.isRtl); serialization::readPod(file, blockStyle.directionDefined); - return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), - std::move(wordFocusBoundary), std::move(wordFocusSuffixX), - blockStyle)); + return block; } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 5f4bf80e1c..38f24f5349 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -9,42 +9,83 @@ #include "Block.h" #include "BlockStyle.h" -// Represents a line of text on a page +// Represents a line of text on a page. +// +// All per-word data lives in ONE flat heap allocation (the arena) instead of +// six parallel vectors: a resident page holds ~25-30 of these blocks, and the +// vector-of-string layout cost ~250 throwing allocations per page load, which +// was the primary driver of heap fragmentation on the ESP32-C3. +// +// Arena layout, in order (2-byte alignment holds by construction: all 16-bit +// arrays come first and the arena base is allocator-aligned; RISC-V faults on +// unaligned multi-byte access): +// uint16_t textOff[wordCount] byte offset of word i's text in text[] +// int16_t xpos[wordCount] +// uint16_t focusSuffixX[wordCount] present only when focusPresent +// uint8_t styles[wordCount] +// uint8_t focusBoundary[wordCount] present only when focusPresent +// char text[textBytes] all words back to back, NUL-terminated +// +// Each word is stored NUL-terminated so render() can hand `text + textOff[i]` +// straight to C APIs (drawText) with no std::string materialization. +// +// Focus split semantics (unchanged from the vector layout): boundary N > 0 +// means the first N bytes of word i render bold, the remainder in the base +// style. N is bounded to 9 codepoints (<= 36 UTF-8 bytes) by the clamp in +// ParsedText::addWord. focusSuffixX is the pre-computed pixel offset from the +// word start to the regular suffix. Both arrays are omitted from the arena +// entirely when no word on the line has a split (zero per-word RAM cost when +// focus reading is disabled). class TextBlock final : public Block { private: - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - // Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold, - // the remainder in the base style. 0 means no split (whole word uses wordStyles[i]). - // N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by - // FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t. - // Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost - // when focus reading is disabled, or on lines that happen to contain no splittable words). - std::vector wordFocusBoundary; - // Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0. - // Eliminates getTextAdvanceX from the render path. 0 when boundary == 0. - // Empty in lockstep with wordFocusBoundary. - std::vector wordFocusSuffixX; BlockStyle blockStyle; + uint16_t numWords = 0; + uint16_t textBytes = 0; // total size of the text region, including NULs + bool focusPresent = false; + bool isValid = true; + // The ONLY allocation: makeUniqueNoThrow, so OOM yields an invalid block + // instead of abort() (bare new is not nothrow with -fno-exceptions). + std::unique_ptr arena; + // Typed views into the arena, bound once after the arena is filled. All + // 16-bit bases sit at even offsets, so direct dereference is alignment-safe. + const uint16_t* textOffArr = nullptr; + const int16_t* xposArr = nullptr; + const uint16_t* focusSuffixXArr = nullptr; // null when !focusPresent + const uint8_t* stylesArr = nullptr; + const uint8_t* focusBoundaryArr = nullptr; // null when !focusPresent + const char* textArr = nullptr; + + TextBlock() = default; // deserialize() fills the fields directly + static size_t arenaSize(uint16_t wordCount, bool hasFocus, uint16_t textBytes); + void bindArenaPointers(); public: - explicit TextBlock(std::vector words, std::vector word_xpos, - std::vector word_styles, std::vector focus_boundary, - std::vector focus_suffix_x, const BlockStyle& blockStyle = BlockStyle()) - : words(std::move(words)), - wordXpos(std::move(word_xpos)), - wordStyles(std::move(word_styles)), - wordFocusBoundary(std::move(focus_boundary)), - wordFocusSuffixX(std::move(focus_suffix_x)), - blockStyle(blockStyle) {} + // Flatten-on-construct: copies the layout-time vectors into the arena; the + // vectors die with the caller. On arena OOM the block is empty and valid() + // is false -- callers must check and fail the line instead of using it. + explicit TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& focusBoundary, + const std::vector& focusSuffixX, const BlockStyle& blockStyle = BlockStyle()); ~TextBlock() override = default; + TextBlock(const TextBlock&) = delete; + TextBlock& operator=(const TextBlock&) = delete; + void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } const BlockStyle& getBlockStyle() const { return blockStyle; } - const std::vector& getWords() const { return words; } - bool isEmpty() override { return words.empty(); } - size_t wordCount() const { return words.size(); } - // given a renderer works out where to break the words into lines + bool isEmpty() override { return numWords == 0; } + bool valid() const { return isValid; } + uint16_t wordCount() const { return numWords; } + // NUL-terminated by construction; safe to pass to C APIs directly. + const char* wordText(const uint16_t i) const { return textArr + textOffArr[i]; } + uint16_t wordTextLen(const uint16_t i) const { + const uint16_t end = (i + 1 < numWords) ? textOffArr[i + 1] : textBytes; + return end - textOffArr[i] - 1; // exclude the NUL + } + int16_t wordXpos(const uint16_t i) const { return xposArr[i]; } + EpdFontFamily::Style wordStyle(const uint16_t i) const { return static_cast(stylesArr[i]); } + uint8_t focusBoundary(const uint16_t i) const { return focusPresent ? focusBoundaryArr[i] : 0; } + uint16_t focusSuffixX(const uint16_t i) const { return focusPresent ? focusSuffixXArr[i] : 0; } + void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } bool serialize(HalFile& file) const; From 3c5c5e8aa7b2d81991d1f89b90fc32e9f1e2a04c Mon Sep 17 00:00:00 2001 From: Pietro Campagnano Date: Mon, 6 Jul 2026 22:45:22 +0200 Subject: [PATCH 08/68] feat: preview image files inline in web file browser (#2429) --- src/network/html/FilesPage.html | 78 ++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 8572b3e102..23881a7e5d 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -169,6 +169,27 @@ .modal.picker-mode { max-width: 920px; } + .modal.image-preview-mode { + max-width: 640px; + } + .image-preview-stage { + display: flex; + justify-content: center; + align-items: center; + overflow: auto; + margin-bottom: 15px; + border-radius: 6px; + } + .image-preview-stage img { + max-width: 100%; + max-height: 65vh; + object-fit: contain; + } + #imagePreviewDownload { + display: inline-block; + width: auto; + text-decoration: none; + } .picker-columns.picker-active { display: flex; flex-direction: row; @@ -1779,6 +1800,21 @@

    📂 Move File

    + + +