diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 12ef05eb..2de11f58 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()); + } + + 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"); + } } - // Remove any incomplete file from previous attempt before retrying - if (Storage.exists(tmpHtmlPath.c_str())) { - Storage.remove(tmpHtmlPath.c_str()); + if (!streamed) { + LOG_ERR("SCT", "Failed to stream item contents to temp file after retries"); + return false; } - HalFile tmpHtml; - if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { - continue; - } - success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); - fileSize = tmpHtml.size(); - // Explicitly close() file before calling Storage.remove() - tmpHtml.close(); + LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize); - // 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"); + // 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"); - return false; - } - - LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize); - - if (!Storage.openFileForWrite("SCT", filePath, file)) { + 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); - std::vector lut = {}; + + 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; + } + // 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); - Hyphenator::setPreferredLanguage(epub->getLanguage()); - success = visitor.parseAndBuildPages(); - - Storage.remove(tmpHtmlPath.c_str()); - if (!success) { - LOG_ERR("SCT", "Failed to parse XML and build pages"); - // Explicitly close() file before calling Storage.remove() + 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(filePath.c_str()); - if (cssParser) { - cssParser->clear(); - } + Storage.remove(binTmpPath().c_str()); + if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str()); return false; } + Hyphenator::setPreferredLanguage(epub->getLanguage()); + build_ = std::move(ctx); + + if (!build_->parser->beginParse()) { + LOG_ERR("SCT", "Failed to begin parse"); + abandonBuild(); + return false; + } + build_->totalBytes = build_->parser->parseTotalBytes(); + return true; +} + +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; + } + 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; +} + +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(binTmpPath().c_str()); + return false; + }; + const uint32_t lutOffset = file.position(); - bool hasFailedLutRecords = false; - // Write LUT - for (const auto& entry : lut) { + for (const auto& entry : build_->lut) { if (entry.fileOffset == 0) { - hasFailedLutRecords = true; - break; + LOG_ERR("SCT", "Failed to write LUT due to invalid page positions"); + return failCommit(); } serialization::writePod(file, entry.fileOffset); } - if (hasFailedLutRecords) { - LOG_ERR("SCT", "Failed to write LUT due to invalid page positions"); - // Explicitly close() file before calling Storage.remove() - file.close(); - Storage.remove(filePath.c_str()); - return false; - } - - // 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 ef216608..d90b4558 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 31b94fbf..b04159e7 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; +} - const size_t len = file.read(buf, PARSE_BUFFER_SIZE); +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; + } - if (len == 0 && file.available() > 0) { - LOG_ERR("EHP", "File read error"); - destroyXmlParser(parser); - file.close(); - return false; - } + const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE); - done = file.available() == 0; + if (len == 0 && parseFile_.available() > 0) { + LOG_ERR("EHP", "File read error"); + return ParseStatus::Error; + } - 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); + const int done = parseFile_.available() == 0; - destroyXmlParser(parser); - file.close(); + 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; + } + + 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 4e619ae3..0571a7a4 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 5928ebd7..73877519 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 2f1ab97f..114af6a5 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 cc996967..31a95398 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 5948fd9a..e7f47deb 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..."); + // 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..."); + } - GUI.drawPopup(renderer, tr(STR_INDEXING)); - - 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; + // 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; +bool EpubReaderActivity::applyDeferredReposition() { + if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) { + return false; } - - // 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 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 7e2a4ad2..691a0b07 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 0b1f3f30..fcf44cf3 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 e4c040df..001e3340 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 52625ecc..251030f3 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 b2bbc21c..5fe6e545 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 46d0bdaf..b50bfc6d 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;