feat: Lazy incremental EPUB section indexing (#2452)
Co-authored-by: Uri Tauber <uritaube@gmail.com> Co-authored-by: Julia Nguyen <julia@uxj.io>
This commit is contained in:
co-authored by
Uri Tauber
Julia Nguyen
parent
79b4d63ee2
commit
685d4e88f9
+511
-107
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#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<ChapterHtmlSlimParser> in BuildContext can be
|
||||
// constructed/destroyed where the parser's full definition is visible.
|
||||
Section::Section(const std::shared_ptr<Epub>& 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> 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<uint32_t>(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<void()>& 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<void()>& 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<PageLutEntry> lut = {};
|
||||
|
||||
auto ctx = makeUniqueNoThrow<BuildContext>();
|
||||
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> 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<ChapterHtmlSlimParser>(
|
||||
epub, ctxPtr->parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
|
||||
viewportWidth, viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||
[this, ctxPtr](std::unique_ptr<Page> 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<uint16_t> 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<uint16_t> 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<uint64_t>(partialPageCount_) * partialTotalBytes_ / partialBytesConsumed_;
|
||||
if (est <= pageCount) return pageCount;
|
||||
return est > 60000 ? 60000 : static_cast<uint16_t>(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<uint64_t>(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<float>(raw); // seed on the first estimate
|
||||
} else if (consumed != build_->smoothedAtConsumed) {
|
||||
build_->smoothedEstimate += ALPHA * (static_cast<float>(raw) - build_->smoothedEstimate);
|
||||
}
|
||||
build_->smoothedAtConsumed = consumed;
|
||||
|
||||
const uint64_t est = static_cast<uint64_t>(build_->smoothedEstimate + 0.5f);
|
||||
if (est <= pageCount) return pageCount; // never fewer than the pages already available
|
||||
return est > 60000 ? 60000 : static_cast<uint16_t>(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<uint16_t>(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<uint16_t>(lut.size()));
|
||||
for (const auto& entry : lut) {
|
||||
serialization::writePod(file, static_cast<uint16_t>(build_->lut.size()));
|
||||
for (const auto& entry : build_->lut) {
|
||||
serialization::writePod(file, entry.paragraphIndex);
|
||||
}
|
||||
|
||||
const uint32_t liLutFileOffset = static_cast<uint32_t>(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<Page> 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<uint32_t>(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<Page> Section::loadPageDuringBuild(const int page) {
|
||||
if (!build_ || page < 0 || page >= static_cast<int>(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<Page> 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<Page> Section::loadPage(const int page) {
|
||||
if (page < 0) {
|
||||
return nullptr;
|
||||
}
|
||||
if (build_ && page < static_cast<int>(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<uint16_t> 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);
|
||||
|
||||
+105
-7
@@ -3,11 +3,14 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Epub.h"
|
||||
|
||||
class Page;
|
||||
class GfxRenderer;
|
||||
class ChapterHtmlSlimParser;
|
||||
class CssParser;
|
||||
|
||||
class Section {
|
||||
std::shared_ptr<Epub> epub;
|
||||
@@ -21,16 +24,67 @@ class Section {
|
||||
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
||||
uint32_t onPageComplete(std::unique_ptr<Page> 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<ChapterHtmlSlimParser> parser;
|
||||
std::vector<PageLutEntry> 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<BuildContext> 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<Page> 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<Page> loadPageDuringBuild(int page);
|
||||
|
||||
public:
|
||||
uint16_t pageCount = 0;
|
||||
int currentPage = 0;
|
||||
|
||||
explicit Section(const std::shared_ptr<Epub>& 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>& 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<void()>& popupFn = nullptr);
|
||||
std::unique_ptr<Page> 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<void()>& 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<bool>(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<Page> 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<uint16_t> 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<uint16_t> 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<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
|
||||
|
||||
// Get the page count from the section cache file without fully loading it.
|
||||
std::optional<uint16_t> getCachedPageCount() const;
|
||||
|
||||
|
||||
@@ -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<int>(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<int>(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<TextBlock> line) {
|
||||
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include <climits>
|
||||
@@ -96,6 +97,16 @@ class ChapterHtmlSlimParser {
|
||||
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||
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<TextBlock> line);
|
||||
const std::vector<std::pair<std::string, uint16_t>>& 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; }
|
||||
};
|
||||
|
||||
@@ -278,13 +278,18 @@ class XPathParagraphResolver final : public Print {
|
||||
path.push_back({name, siblingIndex});
|
||||
parentStates.emplace_back();
|
||||
|
||||
// Count both <p> and <li> 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++;
|
||||
|
||||
@@ -709,12 +709,13 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
||||
float intra =
|
||||
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
||||
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||
// Progress-based XPath correctly handles both <p> and <li> 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user