Merge branch 'master' of https://github.com/jpirnay/crosspoint-reader into feat-expose-override

This commit is contained in:
jpirnay
2026-05-23 21:25:08 +02:00
34 changed files with 1641 additions and 178 deletions
+53
View File
@@ -159,6 +159,59 @@ When `startNewTextBlock` reuses an empty text block (the early-return path), `wo
`scripts/generate_spine_toc_edges_epub.py` generates `test/epubs/test_spine_toc_edges.epub`, a purpose-built epub that exercises spine/TOC relationship patterns. See the script header for the full list of edge cases covered.
## Printed-page labels (status bar hint)
The status bar can show the printed-book page number for the current rendered page in addition to the device-relative `currentPage/pageCount` counter, e.g. `(42) 137/305 18%`. The printed-page label is sourced from the EPUB itself; the device counter remains authoritative for spine pagination.
### Three input formats
The reader accepts printed-page data from three places, in priority order:
1. **Inline `doc-pagebreak` markers** in the XHTML (EPUB 3 accessibility convention): any element with `role="doc-pagebreak"` or `epub:type="pagebreak"`. The visible label is read from `aria-label`, falling back to `title`, falling back to the NCX/nav/page-map label if cross-referenced.
2. **EPUB 3 `<nav epub:type="page-list">`** in the nav document (`TocNavParser`).
3. **NCX `<pageList>`** (EPUB 2 extension) and **`page-map.xml`** (EPUB 2.01, a separate top-level manifest item with media-type `application/oebps-page-map+xml`), both parsed by `TocNcxParser` / `PageMapParser`.
Only one of (2), (3a), or (3b) writes the cache file `<book-cache>/pagelist.bin`. The nav parser runs first; the NCX page-list is written only if the nav parser found nothing. The page-map parser runs last and is skipped if `pagelist.bin` already exists. Inline `doc-pagebreak` markers always take effect in addition to the cache file (they are matched at chapter parse time, independent of the cache).
### pagelist.bin format
Written once per book at index time; consumed once per section build. Format:
```text
[uint16_t entryCount]
[String href][String anchor][String label] // repeated entryCount times
```
`href` is the normalised spine href (e.g. `OEBPS/c9_split_000.xhtml`). `anchor` is the fragment id (empty for "start of file"). `label` is the visible printed-page label (e.g. `"42"`, `"iv"`).
### Section parse path
When `Section::createSectionFile` runs for a chapter, it streams `pagelist.bin`, filters to entries whose `href` matches the current spine item, and passes the resulting `(anchor → label)` pairs to `ChapterHtmlSlimParser::setExternalPageBreakAnchors`. During parsing, an element whose `id=` matches a known external anchor is treated as if it carried an inline `doc-pagebreak` marker — the existing `recordPageBreakLabel()` path is reused. The NCX/nav/page-map "start of file" entries (empty anchor) emit their label on the very first element of the chapter.
### Section cache storage
The section cache file (`section.bin`) gained a `pageBreakMap` block: `uint16_t count`, then for each entry `uint16_t pageIndex` + `String label`. The header carries a `pageBreakMapOffset` between `anchorMapOffset` and `paragraphLutOffset`. On reload, `Section::buildPageBreakLabelsFromFile` populates `pageBreakLabels` for status-bar queries.
### Status bar lookup
`Section::getPrintedPageLabelForPage(devicePage)` returns the parenthesised label (e.g. `"(42)"`) for the rendered device page if one or more printed-page anchors land on it. When several labels co-occur on a single device page (short page contains both `page_7` and `page_8`), the result collapses to `"(7/8)"`. Returns `nullopt` when no printed-page anchor falls on this exact device page — in that case the status bar shows only the device counter.
### Sleep overlay lookup
`Section::getPrintedPageLabelFromCache(sectionsDir, spineIndex, page)` is a standalone helper used by `SleepActivity` — it reads the printed-page label directly from `section.bin`'s page-break map without instantiating a `Section` or supplying render parameters. It walks the sections cache directory, finds any cache variant for `spineIndex` (all variants share the same printed-page anchors since those are content-derived), reads its `pageBreakMap` block, and returns the same `"(42)"` formatting as the in-memory query. The function is version-guarded against `SECTION_FILE_VERSION` so a cache from a different layout is skipped silently. Cost: one extra `section.bin` open per sleep entry, skipped when no cache exists.
### "Jump to printed page" navigation
The reader menu exposes a `STR_GO_TO_PRINTED_PAGE` entry, gated on the book having at least one integer-parseable label in `pagelist.bin` (roman-only or empty page lists hide the menu item). Selecting it opens `EpubReaderPrintedPageInputActivity`, a numeric input dialog:
- Up/Down adjust the digit under the cursor by ±1 (with carry). PageBack/PageForward mirror Up/Down so the physical page-turn buttons still work.
- Left/Right move the cursor between digits.
- Confirm returns a `PrintedPageResult { std::string label }`; Back cancels.
- Pre-fills with the printed-page label currently shown on the status bar (stripped of parens), falling back to the lowest integer label in the book.
- Shows the valid integer range underneath ("Range: 1 - 305") and a step hint.
On confirmation, `EpubReaderActivity` resolves the typed label by linear scan through the loaded `pagelist.bin` entries to recover `(href, anchor)`, calls `Epub::resolveHrefToSpineIndex` to get the target spine, and sets `navTarget = NavigationTarget::makeAnchor(anchor)` (or `makePage(0)` for top-of-file entries). The existing anchor-jump infrastructure in the renderer then handles the actual page-resolution and section load. Labels that exist in the dialog's integer range but skip in the book's `pagelist.bin` (e.g. publisher omitted page 17) are logged at DBG and the reader stays put — no navigation happens.
## Performance characteristics
- **Per page turn**: All in-memory. `getTocIndexForPage` (binary search on 1-3 entries), `getTocItem` for title (one file seek to BookMetadataCache -- noted as a future optimization opportunity).
+29
View File
@@ -109,6 +109,14 @@ if (parsedSize != fileSize) {
## `section.bin`
### Current version
`SECTION_FILE_VERSION = 28`. The on-disk layout has evolved past the v21 pattern shown below; the ImHex pattern is preserved for archeology but no longer reflects all fields. Changes since v21 (read `lib/Epub/Epub/Section.cpp` `header::*` constants for the authoritative layout):
- `parseComplete` (`bool`) inserted before `pageCount` so a truncated parse can be detected on reload.
- `paragraphLutOffset` extended: each per-page entry is now `u32 xhtmlByteOffset + u16 paragraphIndex + u16 listItemIndex` (added the running `<li>` count for KOReader list-item XPath sync).
- `pageBreakMapOffset` (`u32`) added in the header between `anchorMapOffset` and `paragraphLutOffset`. The block at that offset stores printed-page labels: `u16 count`, then per entry `u16 pageIndex + String label`. Populated from inline `doc-pagebreak` markers and from the per-book `pagelist.bin` (NCX `<pageList>` / EPUB 3 `<nav epub:type="page-list">` / EPUB 2.01 `page-map.xml`). See `docs/epub-toc-navigation.md` for the source-format selection rules.
### Version 21
ImHex Pattern:
@@ -251,3 +259,24 @@ if (parsedSize != fileSize) {
std::warning(std::format("Unparsed data detected: {} bytes remaining at offset 0x{:X}", fileSize - parsedSize, parsedSize));
}
```
## `pagelist.bin`
Per-book cache file produced at index time from one of the EPUB printed-page sources (NCX `<pageList>`, EPUB 3 nav `<nav epub:type="page-list">`, or EPUB 2.01 `page-map.xml`). Consumed once per section build by `Section::createSectionFile`. Absent for books that have no printed-page data.
```text
u16 entryCount
struct PageListEntry {
String href; // normalised spine href, e.g. "OEBPS/c9_split_000.xhtml"
String anchor; // fragment id; empty means "start of file"
String label; // printed-page label, e.g. "42" or "iv"
}
PageListEntry entries[entryCount];
```
Selection rules (see `docs/epub-toc-navigation.md`):
- The EPUB 3 nav page-list parser runs first.
- The NCX `<pageList>` writer runs only if the nav writer produced nothing.
- The EPUB 2.01 `page-map.xml` writer runs only if `pagelist.bin` doesn't already exist on disk.
- Inline `doc-pagebreak` markers in XHTML are matched at chapter parse time and don't need the cache file; they coexist with whichever source above won.
+135
View File
@@ -6,6 +6,7 @@
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
#include <Serialization.h>
#include <ZipFile.h>
#include <cctype>
@@ -13,11 +14,38 @@
#include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/PageMapParser.h"
#include "Epub/parsers/TocNavParser.h"
#include "Epub/parsers/TocNcxParser.h"
namespace {
// Serialise a list of printed-page entries (href, anchor, label) to pagelist.bin in the
// book cache. Templated on the parser's entry type so both NCX <pageList> and EPUB 3
// <nav epub:type="page-list"> share the same writer.
template <typename Entry>
void writePageListBin(const std::string& cachePath, const std::vector<Entry>& pageList) {
const auto pageListPath = cachePath + "/pagelist.bin";
if (pageList.empty()) {
Storage.remove(pageListPath.c_str());
return;
}
FsFile pageListFile;
if (!Storage.openFileForWrite("EBP", pageListPath, pageListFile)) {
LOG_ERR("EBP", "Could not write pagelist.bin");
return;
}
serialization::writePod(pageListFile, static_cast<uint16_t>(pageList.size()));
for (const auto& entry : pageList) {
serialization::writeString(pageListFile, entry.href);
serialization::writeString(pageListFile, entry.anchor);
serialization::writeString(pageListFile, entry.label);
}
pageListFile.flush();
pageListFile.close();
LOG_DBG("EBP", "Wrote pagelist.bin with %u entries", static_cast<unsigned>(pageList.size()));
}
enum class CoverImageFormat { Unknown, Jpeg, Png };
CoverImageFormat detectCoverImageFormat(FsFile& imageFile) {
@@ -311,6 +339,10 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, OpfCac
tocNavItem = opfParser.tocNavPath;
}
if (!opfParser.pageMapPath.empty()) {
pageMapItem = opfParser.pageMapPath;
}
if (!opfParser.cssFiles.empty()) {
cssFiles = opfParser.cssFiles;
}
@@ -373,6 +405,12 @@ bool Epub::parseTocNcxFile() const {
tempNcxFile.close();
Storage.remove(tmpNcxPath.c_str());
// Persist the printed-page list (NCX <pageList>) to a small cache file so the
// section builder can stamp printed-page labels onto rendered pages without
// re-parsing the NCX. Format: u16 count, then per entry: writeString(href),
// writeString(anchor), writeString(label).
writePageListBin(getCachePath(), ncxParser.getPageList());
LOG_DBG("EBP", "Parsed TOC items");
return true;
}
@@ -430,10 +468,74 @@ bool Epub::parseTocNavFile() const {
tempNavFile.close();
Storage.remove(tmpNavPath.c_str());
// Persist EPUB 3 <nav epub:type="page-list"> entries to pagelist.bin (same format
// as the NCX writer); the section builder consumes either source uniformly.
writePageListBin(getCachePath(), navParser.getPageList());
LOG_DBG("EBP", "Parsed TOC nav items");
return true;
}
bool Epub::parsePageMapFile() const {
// EPUB 2.01 page-map.xml: a separate top-level manifest item with media-type
// "application/oebps-page-map+xml". Structure is a flat list of <page name="..." href="..."/>.
if (pageMapItem.empty()) {
LOG_DBG("EBP", "No page-map file specified");
return false;
}
LOG_DBG("EBP", "Parsing page-map file: %s", pageMapItem.c_str());
const auto tmpPageMapPath = getCachePath() + "/page-map.xml";
FsFile tempPageMapFile;
if (!Storage.openFileForWrite("EBP", tmpPageMapPath, tempPageMapFile)) {
return false;
}
readItemContentsToStream(pageMapItem, tempPageMapFile, 1024);
tempPageMapFile.close();
if (!Storage.openFileForRead("EBP", tmpPageMapPath, tempPageMapFile)) {
return false;
}
const auto pageMapSize = tempPageMapFile.size();
// page-map hrefs are relative to the page-map file itself (typically content.opf's dir).
const std::string pageMapBasePath = pageMapItem.substr(0, pageMapItem.find_last_of('/') + 1);
PageMapParser pageMapParser(pageMapBasePath, pageMapSize);
if (!pageMapParser.setup()) {
LOG_ERR("EBP", "Could not setup page-map parser");
tempPageMapFile.close();
return false;
}
const auto pageMapBuffer = static_cast<uint8_t*>(malloc(1024));
if (!pageMapBuffer) {
LOG_ERR("EBP", "Could not allocate memory for page-map parser");
tempPageMapFile.close();
return false;
}
while (tempPageMapFile.available()) {
const auto readSize = tempPageMapFile.read(pageMapBuffer, 1024);
if (readSize == 0) break;
const auto processedSize = pageMapParser.write(pageMapBuffer, readSize);
if (processedSize != readSize) {
LOG_ERR("EBP", "Could not process all page-map data");
free(pageMapBuffer);
tempPageMapFile.close();
return false;
}
}
free(pageMapBuffer);
tempPageMapFile.close();
Storage.remove(tmpPageMapPath.c_str());
writePageListBin(getCachePath(), pageMapParser.getPageList());
LOG_DBG("EBP", "Parsed page-map entries");
return true;
}
void Epub::parseCssFiles() const {
// Maximum CSS file size we'll attempt to parse (uncompressed)
// Larger files risk memory exhaustion on ESP32
@@ -607,6 +709,15 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Continue anyway - book will work without TOC
}
// EPUB 2.01 page-map.xml — only parse if neither NCX <pageList> nor nav page-list
// wrote a pagelist.bin already (so an explicit NCX/nav printed-page list always wins).
if (!pageMapItem.empty()) {
const auto pageListPath = getCachePath() + "/pagelist.bin";
if (!Storage.exists(pageListPath.c_str())) {
parsePageMapFile();
}
}
if (!bookMetadataCache->endTocPass()) {
LOG_ERR("EBP", "Could not end writing toc pass");
return false;
@@ -1189,6 +1300,30 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp
return totalProgress / static_cast<float>(bookSize);
}
std::vector<Epub::PrintedPageEntry> Epub::loadPrintedPageList() const {
std::vector<PrintedPageEntry> entries;
const auto pageListPath = getCachePath() + "/pagelist.bin";
if (!Storage.exists(pageListPath.c_str())) {
return entries;
}
FsFile f;
if (!Storage.openFileForRead("EBP", pageListPath, f)) {
return entries;
}
uint16_t count = 0;
serialization::readPod(f, count);
entries.reserve(count);
for (uint16_t i = 0; i < count; i++) {
PrintedPageEntry e;
serialization::readString(f, e.href);
serialization::readString(f, e.anchor);
serialization::readString(f, e.label);
entries.push_back(std::move(e));
}
f.close();
return entries;
}
int Epub::resolveHrefToSpineIndex(const std::string& href) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) return -1;
+14
View File
@@ -17,6 +17,8 @@ class Epub {
std::string tocNcxItem;
// the nav file (EPUB 3)
std::string tocNavItem;
// the page-map.xml file (EPUB 2.01 printed page list, separate from NCX <pageList>)
std::string pageMapItem;
// where is the EPUBfile?
std::string filepath;
// the base path for items in the EPUB file
@@ -38,6 +40,7 @@ class Epub {
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, OpfCacheMode cacheMode);
bool parseTocNcxFile() const;
bool parseTocNavFile() const;
bool parsePageMapFile() const;
void parseCssFiles() const;
public:
@@ -86,4 +89,15 @@ class Epub {
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
CssParser* getCssParser() const { return cssParser.get(); }
int resolveHrefToSpineIndex(const std::string& href) const;
// Printed-page list (from NCX <pageList> / EPUB 3 nav page-list / EPUB 2.01 page-map.xml).
// One entry per printed-page anchor: spine href + fragment id + visible label.
struct PrintedPageEntry {
std::string href;
std::string anchor;
std::string label;
};
// Reads <cachePath>/pagelist.bin. Returns empty vector when the book has no printed-page data.
// Inexpensive — only invoked from menu paths, not page-turn hot paths.
std::vector<PrintedPageEntry> loadPrintedPageList() const;
};
+173 -3
View File
@@ -32,7 +32,8 @@ constexpr uint32_t kParseComplete = kImageRendering + sizeof(uint8_t);
constexpr uint32_t kPageCount = kParseComplete + sizeof(bool);
constexpr uint32_t kPageLut = kPageCount + sizeof(uint16_t);
constexpr uint32_t kAnchorMap = kPageLut + sizeof(uint32_t);
constexpr uint32_t kParagraphLut = kAnchorMap + sizeof(uint32_t);
constexpr uint32_t kPageBreakMap = kAnchorMap + sizeof(uint32_t);
constexpr uint32_t kParagraphLut = kPageBreakMap + sizeof(uint32_t);
constexpr uint32_t kSize = kParagraphLut + sizeof(uint32_t);
} // namespace header
@@ -211,7 +212,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
sizeof(viewportWidth) + sizeof(viewportHeight) + sizeof(hyphenationEnabled) +
sizeof(embeddedStyle) + sizeof(bionicReadingEnabled) + sizeof(imageRendering) +
sizeof(bool) + sizeof(pageCount) + sizeof(uint32_t) + sizeof(uint32_t) +
sizeof(uint32_t),
sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@@ -228,6 +229,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
serialization::writePod(file,
static_cast<uint32_t>(0)); // Placeholder for page break label map offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for paragraph LUT offset (patched later)
}
@@ -339,6 +342,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
// Build TOC boundaries by scanning anchor data from the still-open file,
// matching only the TOC anchors we need (avoids loading all anchors into memory).
buildTocBoundariesFromFile(file);
buildPageBreakLabelsFromFile(file);
// File is intentionally left open; subsequent loadPageFromSectionFile() calls
// seek within this handle instead of re-opening the file each time.
@@ -456,11 +460,35 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
}
// Load printed-page list entries (NCX <pageList> or EPUB 3 nav page-list) for this
// chapter's href, if any. Format: u16 count, then per entry: writeString(href),
// writeString(anchor), writeString(label).
std::vector<std::pair<std::string, std::string>> externalPageBreakAnchors;
{
const auto pageListPath = epub->getCachePath() + "/pagelist.bin";
FsFile pageListFile;
if (Storage.exists(pageListPath.c_str()) && Storage.openFileForRead("SCT", pageListPath, pageListFile)) {
uint16_t count = 0;
serialization::readPod(pageListFile, count);
for (uint16_t i = 0; i < count; i++) {
std::string href, anchor, label;
serialization::readString(pageListFile, href);
serialization::readString(pageListFile, anchor);
serialization::readString(pageListFile, label);
if (href == localPath) {
externalPageBreakAnchors.emplace_back(std::move(anchor), std::move(label));
}
}
pageListFile.close();
}
}
ChapterHtmlSlimParser visitor(
epub, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight,
hyphenationEnabled, bionicReadingEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), progressFn, cssParser);
visitor.setExternalPageBreakAnchors(std::move(externalPageBreakAnchors));
Hyphenator::setPreferredLanguage(epub->getLanguage());
if (!visitor.setup(inflatedSize)) {
@@ -551,6 +579,15 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, page);
}
// Write printed page label map for EPUB pagebreak markers.
const uint32_t pageBreakMapOffset = file.position();
const auto& pageBreakLabels = visitor.getPageBreakLabels();
serialization::writePod(file, static_cast<uint16_t>(pageBreakLabels.size()));
for (const auto& [page, label] : pageBreakLabels) {
serialization::writePod(file, page);
serialization::writeString(file, label);
}
// Write per-page paragraph LUT: count + array of {xhtmlByteOffset(u32), paragraphIndex(u16)}.
// The byte offset lets findXPathForParagraph seek near the target paragraph without scanning
// from the beginning of the XHTML file, reducing SD reads on large chapters.
@@ -582,11 +619,13 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
serialization::writePod(file, anchorMapOffset);
serialization::writePod(file, pageBreakMapOffset);
serialization::writePod(file, paragraphLutOffset);
file.flush();
const size_t expectedHeaderPatchEnd = headerPatchStart + sizeof(parseComplete) + sizeof(pageCount) +
sizeof(lutOffset) + sizeof(anchorMapOffset) + sizeof(paragraphLutOffset);
sizeof(lutOffset) + sizeof(anchorMapOffset) + sizeof(pageBreakMapOffset) +
sizeof(paragraphLutOffset);
if (file.position() != expectedHeaderPatchEnd) {
LOG_ERR("SCT", "Section header patch write failed: wrote %u bytes at offset %u",
static_cast<unsigned>(file.position() - headerPatchStart), static_cast<unsigned>(headerPatchStart));
@@ -601,6 +640,14 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
buildTocBoundaries(anchors);
// Populate in-memory pageBreakLabels from the just-completed parse so the status bar
// can show printed-page labels without having to reload the section cache from disk.
// Without this, labels only appear after a subsequent open (via buildPageBreakLabelsFromFile).
this->pageBreakLabels.clear();
for (const auto& entry : visitor.getPageBreakLabels()) {
this->pageBreakLabels.emplace_back(entry.first, entry.second);
}
file.close();
// Cache the LUT in memory and open the file for reading so that
@@ -758,6 +805,27 @@ void Section::buildTocBoundariesFromFile(FsFile& f) {
[](const TocBoundary& a, const TocBoundary& b) { return a.startPage < b.startPage; });
}
void Section::buildPageBreakLabelsFromFile(FsFile& f) {
pageBreakLabels.clear();
f.seek(header::kPageBreakMap);
uint32_t pageBreakMapOffset;
serialization::readPod(f, pageBreakMapOffset);
if (pageBreakMapOffset == 0 || pageBreakMapOffset >= f.size()) {
return;
}
f.seek(pageBreakMapOffset);
uint16_t count;
serialization::readPod(f, count);
for (uint16_t i = 0; i < count; i++) {
uint16_t page;
std::string label;
serialization::readPod(f, page);
serialization::readString(f, label);
pageBreakLabels.emplace_back(page, std::move(label));
}
}
int Section::getTocIndexForPage(const int page) const {
if (tocBoundaries.empty()) {
return epub->getTocIndexForSpineIndex(spineIndex);
@@ -825,6 +893,108 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
return std::nullopt;
}
std::optional<std::string> Section::getPrintedPageLabelFromCache(const std::string& sectionsDir, int spineIndex,
uint16_t page) {
// Find any cache variant for spineIndex. Filename format: "<spineIndex>_<hash>.bin".
// We pick the first match — all variants for the same spine share the same printed-page
// anchors (those are content-derived, not render-parameter-derived).
char prefix[16];
snprintf(prefix, sizeof(prefix), "%d_", spineIndex);
const auto files = Storage.listFiles(sectionsDir.c_str(), 50);
std::string match;
for (const auto& f : files) {
if (f.startsWith(prefix) && f.endsWith(".bin")) {
match = f.c_str();
break;
}
}
if (match.empty()) {
return std::nullopt;
}
FsFile file;
if (!Storage.openFileForRead("SCT", sectionsDir + "/" + match, file)) {
return std::nullopt;
}
// Header version guard — refuse to read a cache written by a different layout.
uint8_t version = 0;
file.seek(header::kVersion);
serialization::readPod(file, version);
if (version != SECTION_FILE_VERSION) {
file.close();
return std::nullopt;
}
file.seek(header::kPageBreakMap);
uint32_t pageBreakMapOffset = 0;
serialization::readPod(file, pageBreakMapOffset);
if (pageBreakMapOffset == 0 || pageBreakMapOffset >= file.size()) {
file.close();
return std::nullopt;
}
file.seek(pageBreakMapOffset);
uint16_t count = 0;
serialization::readPod(file, count);
std::vector<std::string> labelsOnPage;
for (uint16_t i = 0; i < count; i++) {
uint16_t entryPage = 0;
std::string label;
serialization::readPod(file, entryPage);
serialization::readString(file, label);
if (entryPage == page) {
labelsOnPage.push_back(std::move(label));
} else if (entryPage > page) {
break;
}
}
file.close();
if (labelsOnPage.empty()) {
return std::nullopt;
}
if (labelsOnPage.size() == 1 || labelsOnPage.front() == labelsOnPage.back()) {
return std::string("(") + labelsOnPage.front() + ")";
}
return std::string("(") + labelsOnPage.front() + "/" + labelsOnPage.back() + ")";
}
std::optional<std::string> Section::getNearestPrintedPageLabelAtOrBefore(uint16_t page) const {
// pageBreakLabels is built in document order (i.e. ascending pageIndex), so the last
// entry whose page is <= `page` is the "you're currently reading at or after this
// printed page" hint. Returns the raw label (no parens, no slash-collapsing).
std::optional<std::string> best;
for (const auto& [labelPage, label] : pageBreakLabels) {
if (labelPage > page) break;
best = label;
}
return best;
}
std::optional<std::string> Section::getPrintedPageLabelForPage(uint16_t page) const {
// Collect every printed-page label whose anchor lands on this exact rendered page.
// Multiple labels can co-occur when a short device page contains more than one EPUB
// pagebreak marker (e.g. printed pages 7 and 8 both starting within the same device page).
// pageBreakLabels is recorded in document order, so we can short-circuit once we pass `page`.
std::vector<std::string> labels;
for (const auto& [labelPage, label] : pageBreakLabels) {
if (labelPage == page) {
labels.push_back(label);
} else if (labelPage > page) {
break;
}
}
if (labels.empty()) {
return std::nullopt;
}
if (labels.size() == 1 || labels.front() == labels.back()) {
return std::string("(") + labels.front() + ")";
}
return std::string("(") + labels.front() + "/" + labels.back() + ")";
}
bool Section::readParagraphLutHeader(FsFile& outFile, uint16_t& outCount, uint32_t& outLutStart) const {
if (!Storage.openFileForRead("SCT", filePath, outFile)) {
return false;
+19
View File
@@ -30,9 +30,11 @@ class Section {
uint16_t startPage = 0;
};
std::vector<TocBoundary> tocBoundaries;
std::vector<std::pair<uint16_t, std::string>> pageBreakLabels;
void buildTocBoundaries(const std::vector<std::pair<std::string, uint16_t>>& anchors);
void buildTocBoundariesFromFile(FsFile& f);
void buildPageBreakLabelsFromFile(FsFile& f);
// Open the section file and seek to the first paragraph LUT entry, validating the header
// and LUT bounds against fileSize. On success, returns true with `outLutStart` set to the
@@ -88,6 +90,23 @@ class Section {
// Look up the page number for an anchor id from the section cache file.
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
// Returns the printed-page label for a rendered page, wrapped in parens (e.g. "(42)"), if
// one or more EPUB pagebreak markers / NCX <pageList> / page-map entries land on it.
// Returns nullopt when no printed-page anchor falls on this exact page.
std::optional<std::string> getPrintedPageLabelForPage(uint16_t page) const;
// Like getPrintedPageLabelForPage but returns the most recent printed-page label at or
// before `page` (raw label, no parens). Useful for pre-filling jump-to-page dialogs when
// the current rendered page doesn't itself carry an anchor. Returns nullopt when no
// printed-page anchor exists on this or any earlier page in the section.
std::optional<std::string> getNearestPrintedPageLabelAtOrBefore(uint16_t page) const;
// Standalone lookup that doesn't require a loaded Section. Walks the book's sections cache
// directory, finds any cache variant for `spineIndex`, reads its printed-page label map,
// and returns the parenthesised label for `page` if one is recorded. Returns nullopt when
// no cache exists or the page carries no printed-page anchor. Used by SleepActivity to
// augment the overlay without instantiating a full Section + render parameters.
static std::optional<std::string> getPrintedPageLabelFromCache(const std::string& sectionsDir, int spineIndex,
uint16_t page);
// Look up the page number for a paragraph index (1-based, from XPath p[N]).
// Uses the per-page paragraph LUT stored in the section cache.
@@ -338,6 +338,33 @@ void ChapterHtmlSlimParser::emitPage(uint32_t xhtmlByteOffset) {
currentPageNextY = 0;
}
void ChapterHtmlSlimParser::recordPageBreakLabel(const std::string& label) {
if (label.empty()) {
return;
}
// Record the printed page label for the current rendered section page.
// Do not alter pagination; the reader keeps its own page breaks.
pageBreakLabels.emplace_back(static_cast<uint16_t>(completedPageCount), label);
}
void ChapterHtmlSlimParser::setExternalPageBreakAnchors(std::vector<std::pair<std::string, std::string>> anchors) {
externalPageBreakAnchors.clear();
topOfFilePageLabel.clear();
topOfFilePageLabelEmitted = false;
for (auto& [id, label] : anchors) {
if (id.empty()) {
// NCX pageTarget with no fragment (e.g. "OEBPS/c9_split_000.xhtml") — applies to the
// first rendered page of this chapter. Keep only the first such entry if multiple.
if (topOfFilePageLabel.empty()) {
topOfFilePageLabel = std::move(label);
}
} else {
externalPageBreakAnchors.emplace_back(std::move(id), std::move(label));
}
}
}
// start a new text block if needed
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
nextWordContinues = false; // New block = new paragraph, no continuation
@@ -424,9 +451,13 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return;
}
// Extract class, style, and id attributes
// Extract class, style, id, and pagebreak metadata attributes
std::string classAttr;
std::string styleAttr;
std::string idAttr;
std::string ariaLabel;
std::string titleAttr;
bool isPageBreakMarker = false;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "class") == 0) {
@@ -434,13 +465,58 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
} else if (strcmp(atts[i], "id") == 0) {
// Defer both anchor recording and TOC page breaks until startNewTextBlock,
// after the previous block is flushed to pages via makePages().
self->pendingAnchorId = atts[i + 1];
idAttr = atts[i + 1];
} else if (strcmp(atts[i], "aria-label") == 0) {
ariaLabel = atts[i + 1];
} else if (strcmp(atts[i], "title") == 0) {
titleAttr = atts[i + 1];
} else if (strcmp(atts[i], "role") == 0 && strcmp(atts[i + 1], "doc-pagebreak") == 0) {
isPageBreakMarker = true;
} else if (strcmp(atts[i], "epub:type") == 0 && strcmp(atts[i + 1], "pagebreak") == 0) {
isPageBreakMarker = true;
}
}
}
// Emit any "top-of-file" printed-page label as soon as we see real markup. NCX entries
// without a fragment refer to the start of this XHTML; record now so the label lands on
// page 0 (completedPageCount is still 0 until the first emitPage()).
if (!self->topOfFilePageLabelEmitted && !self->topOfFilePageLabel.empty()) {
self->recordPageBreakLabel(self->topOfFilePageLabel);
self->topOfFilePageLabelEmitted = true;
}
// Match id against NCX-supplied pagebreak anchors (printed page list). If matched,
// treat this element as if it carried an inline doc-pagebreak marker.
std::string externalLabel;
if (!isPageBreakMarker && !idAttr.empty() && !self->externalPageBreakAnchors.empty()) {
for (const auto& [extId, extLabel] : self->externalPageBreakAnchors) {
if (extId == idAttr) {
externalLabel = extLabel;
isPageBreakMarker = true;
break;
}
}
}
if (isPageBreakMarker) {
std::string label = !ariaLabel.empty() ? ariaLabel : titleAttr;
if (label.empty()) {
label = std::move(externalLabel);
}
self->recordPageBreakLabel(label);
if (!idAttr.empty()) {
self->anchorData.emplace_back(idAttr, static_cast<uint16_t>(self->completedPageCount));
self->pendingAnchorId = idAttr;
}
}
// Defer generic anchor recording until startNewTextBlock, after the previous block
// is flushed to pages via makePages(). Skip pagebreak anchors since they were already recorded.
if (!isPageBreakMarker && !idAttr.empty()) {
self->pendingAnchorId = idAttr;
}
auto centeredBlockStyle = BlockStyle();
centeredBlockStyle.textAlignDefined = true;
centeredBlockStyle.alignment = CssTextAlign::Center;
@@ -108,6 +108,20 @@ class ChapterHtmlSlimParser final : public Print {
std::string pendingAnchorId; // deferred until after previous text block is flushed
std::vector<std::string> tocAnchors;
// External printed-page labels sourced from NCX <pageList> or EPUB 3 nav page-list.
// Keyed by HTML id (anchor fragment). When the parser encounters an element whose id
// matches one of these, it records the label as if the element were an inline
// doc-pagebreak marker. Anchors already labeled this way are not re-recorded if the
// same element also carries an inline pagebreak attribute.
std::vector<std::pair<std::string, std::string>> externalPageBreakAnchors;
// Optional label for the start of this XHTML file (NCX entries with no fragment).
std::string topOfFilePageLabel;
bool topOfFilePageLabelEmitted = false;
// Page break label mapping: stores the printed page label from EPUB pagebreak markers
// and the section page index where that printed page begins.
std::vector<std::pair<uint16_t, std::string>> pageBreakLabels;
// Paragraph index tracking for XPath-to-page lookup table.
// Counts <p> sibling indices (1-based, matching XPath convention) during page building.
// Stored per page in the section cache so that XPath p[N] can be resolved to a page
@@ -173,6 +187,7 @@ class ChapterHtmlSlimParser final : public Print {
// in lockstep. Every page break MUST go through this helper; open-coded completePageFn
// calls risk desynchronising paragraphLutPerPage and failing the size check in Section.cpp.
void emitPage(uint32_t xhtmlByteOffset);
void recordPageBreakLabel(const std::string& label);
// XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
@@ -227,5 +242,10 @@ class ChapterHtmlSlimParser final : public Print {
ParsedText::LineProcessResult addLineToPage(std::shared_ptr<TextBlock> line, bool lineEndsWithHyphenatedWord,
bool suppressHyphenationRetry);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
const std::vector<std::pair<uint16_t, std::string>>& getPageBreakLabels() const { return pageBreakLabels; }
const std::vector<ParagraphLutEntry>& getParagraphLutPerPage() const { return paragraphLutPerPage; }
// Supplies printed-page labels from NCX <pageList> for this chapter. `anchors` maps
// HTML id -> label; an entry with an empty id applies to the first page of this file.
void setExternalPageBreakAnchors(std::vector<std::pair<std::string, std::string>> anchors);
};
@@ -9,6 +9,7 @@
namespace {
constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
constexpr char MEDIA_TYPE_CSS[] = "text/css";
constexpr char MEDIA_TYPE_PAGEMAP[] = "application/oebps-page-map+xml";
constexpr char itemCacheFile[] = "/.items.bin";
constexpr size_t MAX_DESCRIPTION_LENGTH = 1024;
@@ -340,6 +341,16 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
}
}
// EPUB 2.01 page-map.xml — separate top-level file mapping printed page numbers to spine
// locations (e.g. <page name="1" href="OEBPS/c9_split_000.xhtml"/>). Spine references it
// via <spine page-map="..."> but only the manifest item carries the canonical href.
if (mediaType == MEDIA_TYPE_PAGEMAP) {
if (self->pageMapPath.empty()) {
self->pageMapPath = href;
LOG_DBG("COF", "Found EPUB 2.01 page-map: %s", href.c_str());
}
}
// Collect CSS files
if (mediaType == MEDIA_TYPE_CSS) {
self->cssFiles.push_back(href);
+1
View File
@@ -81,6 +81,7 @@ class ContentOpfParser final : public Print {
std::string seriesIndex;
std::string tocNcxPath;
std::string tocNavPath; // EPUB 3 nav document path
std::string pageMapPath; // EPUB 2.01 page-map.xml document path
std::string coverItemHref;
std::string guideCoverPageHref; // Guide reference with type="cover" or "cover-page" (points to XHTML wrapper)
std::string textReferenceHref;
+103
View File
@@ -0,0 +1,103 @@
#include "PageMapParser.h"
#include <FsHelpers.h>
#include <Logging.h>
bool PageMapParser::setup() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
LOG_DBG("PMP", "Couldn't allocate memory for parser");
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, nullptr);
return true;
}
PageMapParser::~PageMapParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_ParserFree(parser);
parser = nullptr;
}
}
size_t PageMapParser::write(const uint8_t data) { return write(&data, 1); }
size_t PageMapParser::write(const uint8_t* buffer, const size_t size) {
if (!parser) return 0;
const uint8_t* currentBufferPos = buffer;
auto remainingInBuffer = size;
while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) {
LOG_DBG("PMP", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
const auto toRead = remainingInBuffer < 1024 ? remainingInBuffer : 1024;
memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
LOG_DBG("PMP", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
currentBufferPos += toRead;
remainingInBuffer -= toRead;
remainingSize -= toRead;
}
return size;
}
void XMLCALL PageMapParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<PageMapParser*>(userData);
// We only care about <page name="..." href="..."/> elements. The wrapping <page-map>
// root is ignored (no need for a state machine — every page element carries its data).
const char* localName = strrchr(name, ':');
if (localName) {
localName++;
} else {
localName = name;
}
if (strcmp(localName, "page") != 0) {
return;
}
std::string label;
std::string rawHref;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "name") == 0) {
label = atts[i + 1];
} else if (strcmp(atts[i], "href") == 0) {
rawHref = atts[i + 1];
}
}
if (label.empty() || rawHref.empty()) {
return;
}
std::string href = FsHelpers::normalisePath(self->baseContentPath + rawHref);
std::string anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
self->pageList.push_back({std::move(href), std::move(anchor), std::move(label)});
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <Print.h>
#include <expat.h>
#include <string>
#include <vector>
// Parser for EPUB 2.01 page-map.xml. Each <page name="X" href="...#anchor"/> element
// maps a printed page number to a spine location. Same output shape as TocNcxParser
// and TocNavParser so all three feed the shared pagelist.bin writer.
class PageMapParser final : public Print {
public:
struct PageListEntry {
std::string href;
std::string anchor;
std::string label;
};
private:
const std::string& baseContentPath;
size_t remainingSize;
XML_Parser parser = nullptr;
std::vector<PageListEntry> pageList;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
public:
explicit PageMapParser(const std::string& baseContentPath, const size_t xmlSize)
: baseContentPath(baseContentPath), remainingSize(xmlSize) {}
~PageMapParser() override;
bool setup();
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
const std::vector<PageListEntry>& getPageList() const { return pageList; }
};
+88 -10
View File
@@ -83,14 +83,49 @@ void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, co
return;
}
// Look for <nav epub:type="toc"> anywhere in body (or nested elements)
if (self->state >= IN_BODY && strcmp(name, "nav") == 0) {
// Look for <nav epub:type="toc"> or <nav epub:type="page-list"> anywhere in body.
// Both navs are siblings under <body>; we don't expect them to nest.
if (self->state >= IN_BODY && self->state != IN_NAV_TOC && self->state != IN_NAV_PAGE_LIST &&
strcmp(name, "nav") == 0) {
for (int i = 0; atts[i]; i += 2) {
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
if (strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) {
if (strcmp(atts[i + 1], "toc") == 0) {
self->state = IN_NAV_TOC;
LOG_DBG("NAV", "Found nav toc element");
return;
}
if (strcmp(atts[i + 1], "page-list") == 0) {
self->state = IN_NAV_PAGE_LIST;
LOG_DBG("NAV", "Found nav page-list element");
return;
}
}
}
return;
}
// Page-list nav: parallel state machine (independent ol/li/a tracking).
if (self->state >= IN_NAV_PAGE_LIST && self->state <= IN_PL_ANCHOR) {
if (strcmp(name, "ol") == 0) {
self->plOlDepth++;
self->state = IN_PL_OL;
return;
}
if (self->state == IN_PL_OL && strcmp(name, "li") == 0) {
self->state = IN_PL_LI;
self->currentPageLabel.clear();
self->currentPageHref.clear();
return;
}
if (self->state == IN_PL_LI && strcmp(name, "a") == 0) {
self->state = IN_PL_ANCHOR;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "href") == 0) {
self->currentPageHref = atts[i + 1];
break;
}
}
return;
}
return;
}
@@ -129,15 +164,59 @@ void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, co
void XMLCALL TocNavParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<TocNavParser*>(userData);
// Only collect text when inside an anchor within the TOC nav
// Collect text inside the anchor of either nav (TOC or page-list).
if (self->state == IN_ANCHOR) {
self->currentLabel.append(s, len);
} else if (self->state == IN_PL_ANCHOR) {
self->currentPageLabel.append(s, len);
}
}
void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<TocNavParser*>(userData);
// ---- Page-list nav close handlers (checked before TOC handlers because IN_PL_* states
// sort after IN_NAV_TOC, but we want exact-state matching either way).
if (strcmp(name, "a") == 0 && self->state == IN_PL_ANCHOR) {
if (!self->currentPageLabel.empty() && !self->currentPageHref.empty()) {
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentPageHref);
std::string anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
self->pageList.push_back({std::move(href), std::move(anchor), std::move(self->currentPageLabel)});
self->currentPageLabel.clear();
self->currentPageHref.clear();
}
self->state = IN_PL_LI;
return;
}
if (strcmp(name, "li") == 0 && (self->state == IN_PL_LI || self->state == IN_PL_OL)) {
self->state = IN_PL_OL;
return;
}
if (strcmp(name, "ol") == 0 &&
(self->state == IN_PL_OL || self->state == IN_PL_LI || self->state == IN_NAV_PAGE_LIST)) {
if (self->plOlDepth > 0) {
self->plOlDepth--;
}
self->state = (self->plOlDepth == 0) ? IN_NAV_PAGE_LIST : IN_PL_LI;
return;
}
if (strcmp(name, "nav") == 0 &&
(self->state == IN_NAV_PAGE_LIST || self->state == IN_PL_OL || self->state == IN_PL_LI)) {
self->state = IN_BODY;
self->plOlDepth = 0;
LOG_DBG("NAV", "Finished parsing nav page-list");
return;
}
// ---- TOC nav close handlers
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
// Create TOC entry when closing anchor tag (we have all data now)
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
@@ -167,18 +246,17 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
return;
}
if (strcmp(name, "ol") == 0 && self->state >= IN_NAV_TOC) {
if (strcmp(name, "ol") == 0 && (self->state == IN_OL || self->state == IN_LI || self->state == IN_NAV_TOC)) {
if (self->olDepth > 0) {
self->olDepth--;
if (self->olDepth == 0) {
self->state = IN_NAV_TOC;
} else {
self->state = IN_LI; // Back to parent li
}
self->state = (self->olDepth == 0) ? IN_NAV_TOC : IN_LI;
return;
}
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
if (strcmp(name, "nav") == 0 && (self->state == IN_NAV_TOC || self->state == IN_OL || self->state == IN_LI)) {
self->state = IN_BODY;
self->olDepth = 0;
LOG_DBG("NAV", "Finished parsing nav toc");
return;
}
+30 -4
View File
@@ -3,22 +3,39 @@
#include <expat.h>
#include <string>
#include <vector>
class BookMetadataCache;
// Parser for EPUB 3 nav.xhtml navigation documents
// Parses HTML5 nav elements with epub:type="toc" to extract table of contents
// Parses HTML5 nav elements with epub:type="toc" (table of contents) and
// epub:type="page-list" (printed page list, EPUB 3 equivalent of NCX <pageList>).
class TocNavParser final : public Print {
enum ParserState {
START,
IN_HTML,
IN_BODY,
IN_NAV_TOC, // Inside <nav epub:type="toc">
IN_OL, // Inside <ol>
IN_LI, // Inside <li>
IN_ANCHOR, // Inside <a>
IN_OL, // Inside <ol> (within toc nav)
IN_LI, // Inside <li> (within toc nav)
IN_ANCHOR, // Inside <a> (within toc nav)
IN_NAV_PAGE_LIST, // Inside <nav epub:type="page-list">
IN_PL_OL, // Inside <ol> (within page-list nav)
IN_PL_LI, // Inside <li> (within page-list nav)
IN_PL_ANCHOR, // Inside <a> (within page-list nav)
};
public:
// One printed-page entry from <nav epub:type="page-list">: file href (normalised),
// anchor fragment, and visible label. Matches TocNcxParser::PageListEntry in shape so
// both parsers can feed the same pagelist.bin writer.
struct PageListEntry {
std::string href;
std::string anchor;
std::string label;
};
private:
const std::string& baseContentPath;
size_t remainingSize;
XML_Parser parser = nullptr;
@@ -31,6 +48,13 @@ class TocNavParser final : public Print {
std::string currentLabel;
std::string currentHref;
// Page-list collection state (independent of TOC; structurally identical handlers but
// a separate label/href pair so a malformed nav with overlapping navs cannot mix them).
uint8_t plOlDepth = 0;
std::string currentPageLabel;
std::string currentPageHref;
std::vector<PageListEntry> pageList;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void characterData(void* userData, const XML_Char* s, int len);
static void endElement(void* userData, const XML_Char* name);
@@ -44,4 +68,6 @@ class TocNavParser final : public Print {
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
const std::vector<PageListEntry>& getPageList() const { return pageList; }
};
+70
View File
@@ -96,6 +96,40 @@ void XMLCALL TocNcxParser::startElement(void* userData, const XML_Char* name, co
return;
}
// <pageList> is a sibling of <navMap> and contains <pageTarget> elements that map
// printed page numbers to spine locations (e.g. "OEBPS/c9_split_000.xhtml#page_3").
if (self->state == IN_NCX && strcmp(name, "pageList") == 0) {
self->state = IN_PAGE_LIST;
return;
}
if (self->state == IN_PAGE_LIST && strcmp(name, "pageTarget") == 0) {
self->state = IN_PAGE_TARGET;
self->currentPageLabel.clear();
self->currentPageSrc.clear();
return;
}
if (self->state == IN_PAGE_TARGET && strcmp(name, "navLabel") == 0) {
self->state = IN_PAGE_TARGET_LABEL;
return;
}
if (self->state == IN_PAGE_TARGET_LABEL && strcmp(name, "text") == 0) {
self->state = IN_PAGE_TARGET_LABEL_TEXT;
return;
}
if (self->state == IN_PAGE_TARGET && strcmp(name, "content") == 0) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "src") == 0) {
self->currentPageSrc = atts[i + 1];
break;
}
}
return;
}
// Handles both top-level and nested navPoints
if ((self->state == IN_NAV_MAP || self->state == IN_NAV_POINT) && strcmp(name, "navPoint") == 0) {
self->state = IN_NAV_POINT;
@@ -131,6 +165,8 @@ void XMLCALL TocNcxParser::characterData(void* userData, const XML_Char* s, cons
auto* self = static_cast<TocNcxParser*>(userData);
if (self->state == IN_NAV_LABEL_TEXT) {
self->currentLabel.append(s, len);
} else if (self->state == IN_PAGE_TARGET_LABEL_TEXT) {
self->currentPageLabel.append(s, len);
}
}
@@ -177,5 +213,39 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
self->currentLabel.clear();
self->currentSrc.clear();
}
return;
}
// <pageList> closing handlers
if (self->state == IN_PAGE_TARGET_LABEL_TEXT && strcmp(name, "text") == 0) {
self->state = IN_PAGE_TARGET_LABEL;
return;
}
if (self->state == IN_PAGE_TARGET_LABEL && strcmp(name, "navLabel") == 0) {
self->state = IN_PAGE_TARGET;
return;
}
if (self->state == IN_PAGE_TARGET && strcmp(name, "pageTarget") == 0) {
if (!self->currentPageLabel.empty() && !self->currentPageSrc.empty()) {
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentPageSrc);
std::string anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
self->pageList.push_back({std::move(href), std::move(anchor), self->currentPageLabel});
}
self->currentPageLabel.clear();
self->currentPageSrc.clear();
self->state = IN_PAGE_LIST;
return;
}
if (self->state == IN_PAGE_LIST && strcmp(name, "pageList") == 0) {
self->state = IN_NCX;
return;
}
}
+30 -1
View File
@@ -3,12 +3,34 @@
#include <expat.h>
#include <string>
#include <vector>
class BookMetadataCache;
class TocNcxParser final : public Print {
enum ParserState { START, IN_NCX, IN_NAV_MAP, IN_NAV_POINT, IN_NAV_LABEL, IN_NAV_LABEL_TEXT, IN_CONTENT };
enum ParserState {
START,
IN_NCX,
IN_NAV_MAP,
IN_NAV_POINT,
IN_NAV_LABEL,
IN_NAV_LABEL_TEXT,
IN_CONTENT,
IN_PAGE_LIST,
IN_PAGE_TARGET,
IN_PAGE_TARGET_LABEL,
IN_PAGE_TARGET_LABEL_TEXT,
};
public:
// One printed-page reference from <pageList>: file href (normalised) + anchor fragment + visible label.
struct PageListEntry {
std::string href; // normalised path to spine item
std::string anchor; // fragment (empty = top of file)
std::string label; // value shown to the reader (e.g. "1", "iv")
};
private:
const std::string& baseContentPath;
size_t remainingSize;
XML_Parser parser = nullptr;
@@ -19,6 +41,11 @@ class TocNcxParser final : public Print {
std::string currentSrc;
uint8_t currentDepth = 0;
// <pageList> collection state
std::string currentPageLabel;
std::string currentPageSrc;
std::vector<PageListEntry> pageList;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void characterData(void* userData, const XML_Char* s, int len);
static void endElement(void* userData, const XML_Char* name);
@@ -32,4 +59,6 @@ class TocNcxParser final : public Print {
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
const std::vector<PageListEntry>& getPageList() const { return pageList; }
};
+4
View File
@@ -382,6 +382,10 @@ STR_HW_CONFIRM_LABEL: "Confirm (2nd button)"
STR_HW_LEFT_LABEL: "Left (3rd button)"
STR_HW_RIGHT_LABEL: "Right (4th button)"
STR_GO_TO_PERCENT: "Go to %"
STR_GO_TO_PRINTED_PAGE: "Go to printed page"
STR_GO_TO_PRINTED_PAGE_RANGE: "Range: %u - %u"
STR_GO_TO_PRINTED_PAGE_HINT: "Up/Down: ±1 (x2: ±10) Left/Right: digit"
STR_GO_TO_PRINTED_PAGE_NOT_FOUND: "Page %s not found"
STR_GO_HOME_BUTTON: "Go Home"
STR_SYNC_PROGRESS: "Sync Progress"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Push progress from this device"
+46 -38
View File
@@ -19,14 +19,24 @@ namespace {
// Strategy:
// 1) Count total visible text bytes in chapter.
// 2) Stream parse again and stop when target byte offset is reached.
// 3) Emit /text()[N].M relative to the deepest open element so KOReader can
// place the cursor at character precision regardless of nesting depth.
// 3) Emit either /text()[N].M when the cursor is at a direct text child of
// <body>, or the bare element path otherwise.
//
// Text-node counting matches KOReader/crengine: the Nth XML text node within
// an element, including whitespace-only nodes (those are still real DOM text
// nodes). Empty (len=0) text isn't emitted by expat at all, which mirrors
// KOReader's behavior of skipping the empty text nodes that bare
// <a id="anchor"/> elements would otherwise produce.
// Why body-level only (and not deep nested /p[i]/span[j]/text()[k].M):
// KOReader's crengine normalises the DOM differently than expat — it merges
// adjacent inline elements, drops empty wrappers, and renumbers text nodes
// inside <p>/<span>/<em>. A deep XPath we emit (e.g. /p[17]/span[1]/text()[1].26)
// often fails to match crengine's tree, and KOReader stores a degraded
// fallback position (start-of-wrapper-div or off-by-N text node) that
// round-trips back to the wrong page on pull. Body-level text-point XPaths
// have a much higher round-trip success rate even though they sacrifice
// character-precision inside paragraphs. The Section paragraph LUT then
// snaps the pulled position to the correct page anyway, so the precision
// loss is invisible to users.
//
// This matches the 1.42 behavior. The pre-1.43 forward mapper only emitted
// text-point XPaths when the cursor was a direct text child of <body>; the
// 1.43 change to deep emission is the regression we're undoing here.
struct ForwardState : StackState {
int spineIndex;
@@ -35,32 +45,24 @@ struct ForwardState : StackState {
bool found = false;
XML_Parser parser = nullptr;
// Per-element text-node bookkeeping. Mirrors `stack` 1:1 — every push/pop
// appends/removes a counter so the top of the stack always refers to the
// currently open element. `pendingTextNode` is set after every element
// boundary so the next char data starts a fresh text node within whatever
// element is currently on top.
std::vector<int> textNodeIndexStack;
std::vector<size_t> codepointsInTextNodeStack;
bool pendingTextNode = true;
// Body-level text-node bookkeeping: only counts text nodes that are direct
// children of <body>. Inline-element text contributes to totalTextBytes via
// the StackState base, but does not advance bodyTextNodeCount because
// KOReader can't round-trip a deep text-node XPath reliably.
int bodyTextNodeCount = 0;
size_t codepointsInBodyTextNode = 0;
bool inBodyTextNode = false;
ForwardState(const int spineIndex, const size_t targetOffset) : spineIndex(spineIndex), targetOffset(targetOffset) {
textNodeIndexStack.reserve(32);
codepointsInTextNodeStack.reserve(32);
}
ForwardState(const int spineIndex, const size_t targetOffset) : spineIndex(spineIndex), targetOffset(targetOffset) {}
void onStartElement(const XML_Char* rawName) {
inBodyTextNode = false;
pushElement(rawName);
textNodeIndexStack.push_back(0);
codepointsInTextNodeStack.push_back(0);
pendingTextNode = true;
}
void onEndElement() {
inBodyTextNode = false;
popElement();
if (!textNodeIndexStack.empty()) textNodeIndexStack.pop_back();
if (!codepointsInTextNodeStack.empty()) codepointsInTextNodeStack.pop_back();
pendingTextNode = true;
}
void onCharData(const XML_Char* text, const int len) {
@@ -68,30 +70,34 @@ struct ForwardState : StackState {
return;
}
if (pendingTextNode) {
if (!textNodeIndexStack.empty()) textNodeIndexStack.back()++;
if (!codepointsInTextNodeStack.empty()) codepointsInTextNodeStack.back() = 0;
pendingTextNode = false;
const bool atBodyLevel = bodyIdx() + 1 == static_cast<int>(stack.size());
if (atBodyLevel && !inBodyTextNode) {
inBodyTextNode = true;
bodyTextNodeCount++;
codepointsInBodyTextNode = 0;
}
const size_t cpCount = countUtf8Codepoints(text, len);
if (isWhitespaceOnly(text, len)) {
if (!codepointsInTextNodeStack.empty()) codepointsInTextNodeStack.back() += cpCount;
if (atBodyLevel) {
codepointsInBodyTextNode += countUtf8Codepoints(text, len);
}
return;
}
const size_t visible = countVisibleBytes(text, len);
if (totalTextBytes + visible >= targetOffset) {
const int textNode = textNodeIndexStack.empty() ? 0 : textNodeIndexStack.back();
const size_t cpsInNode = codepointsInTextNodeStack.empty() ? 0 : codepointsInTextNodeStack.back();
if (atBodyLevel && bodyTextNodeCount > 0) {
// KOReader/crengine text-point semantics use codepoint offsets.
const size_t targetVisibleByteInChunk = targetOffset - totalTextBytes;
const size_t cpInChunk = codepointAtVisibleByte(text, len, targetVisibleByteInChunk);
const size_t charOff = cpsInNode + cpInChunk;
if (textNode > 0) {
result = currentXPath(spineIndex) + "/text()[" + std::to_string(textNode) + "]." + std::to_string(charOff);
const size_t charOff = codepointsInBodyTextNode + cpInChunk;
result =
currentXPath(spineIndex) + "/text()[" + std::to_string(bodyTextNodeCount) + "]." + std::to_string(charOff);
} else {
// Cursor is inside a nested element. Emit the element path without a
// text-point suffix — KOReader will treat this as a position at the
// start of the named element, which is good enough for paragraph-level
// accuracy. Don't emit a deep text() index here: see header comment.
result = currentXPath(spineIndex);
}
found = true;
@@ -102,7 +108,9 @@ struct ForwardState : StackState {
}
totalTextBytes += visible;
if (!codepointsInTextNodeStack.empty()) codepointsInTextNodeStack.back() += cpCount;
if (atBodyLevel) {
codepointsInBodyTextNode += countUtf8Codepoints(text, len);
}
}
};
+92 -6
View File
@@ -43,6 +43,60 @@ bool resolveFromPercentage(const std::shared_ptr<Epub>& epub, const float percen
return true;
}
// Compute intra-spine progress from KOReader's book percentage, assuming the target
// spine is known. This is the constrained version of resolveFromPercentage that
// honors an XPath-derived spine index even when the heavy XPath resolver couldn't
// run (typically because heap was too fragmented to inflate the chapter at sync time).
//
// The math is identical to the per-spine portion of resolveFromPercentage. Returns 0
// when the percentage maps to bytes before the spine's start (the position lives
// inside the spine by assumption, so clamp to 0) and 1 when it overshoots the end.
float intraSpineFromPercentage(const std::shared_ptr<Epub>& epub, const int spineIndex, const float percentage) {
if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount() || !std::isfinite(percentage)) {
return 0.0f;
}
const size_t bookSize = epub->getBookSize();
if (bookSize == 0) {
return 0.0f;
}
const float sanitized = std::clamp(percentage, 0.0f, 1.0f);
const size_t targetBytes = static_cast<size_t>(bookSize * sanitized);
const size_t prevCumSize = (spineIndex > 0) ? epub->getCumulativeSpineItemSize(spineIndex - 1) : 0;
const size_t currentCumSize = epub->getCumulativeSpineItemSize(spineIndex);
const size_t spineSize = currentCumSize - prevCumSize;
if (spineSize == 0) {
return 0.0f;
}
if (targetBytes <= prevCumSize) {
return 0.0f;
}
const size_t bytesIntoSpine = targetBytes - prevCumSize;
return std::clamp(static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize), 0.0f, 1.0f);
}
// KOReader emits chapter-start XPaths as ".../body/<wrapper>.0" or just
// ".../body/text()[1].0" — there's no paragraph segment, and the character offset is 0.
// These unambiguously denote "the start of the spine"; we can pin intra=0 without
// inflating the chapter. Catches the common case of starting a new chapter on
// another device, which previously round-tripped through book-percentage byte math
// and landed several pages into the chapter due to byte-vs-page-density skew.
bool isChapterStartXPath(const std::string& xpath) {
// Reject anything with a paragraph or list-item predicate — those carry real
// position information that can't be flattened to "start of spine".
if (xpath.find("/p[") != std::string::npos) return false;
if (xpath.find("/li[") != std::string::npos) return false;
// The path must end with a ".0" text-point segment. The reverse mapper already
// strips text() suffixes for matching, but here we look at the raw form: either
// "<tag>.0" (cursor at start of element) or "text()[1].0" / similar (cursor at
// start of the first text node) with no following character offset.
const size_t dotPos = xpath.rfind('.');
if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return false;
for (size_t i = dotPos + 1; i < xpath.size(); i++) {
if (xpath[i] != '0') return false;
}
return true;
}
} // namespace
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
@@ -101,9 +155,14 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
bool usedXPathMapping = false;
bool usedPercentageReconcile = false;
// Mapping source used for the final log line; updated as we narrow down the path
// actually taken (xpath / xpath+percentage / xpath-spine+percentage / percentage).
const char* mappingSource = "percentage";
int xpathSpineIndex = -1;
if (ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) && xpathSpineIndex >= 0 &&
xpathSpineIndex < spineCount) {
const bool haveXPathSpine = ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) &&
xpathSpineIndex >= 0 && xpathSpineIndex < spineCount;
if (haveXPathSpine) {
float intraFromXPath = 0.0f;
uint16_t liIndexFromXPath = 0;
if (ChapterXPathIndexer::findProgressForXPath(epub, xpathSpineIndex, koPos.xpath, intraFromXPath, xpathExactMatch,
@@ -139,8 +198,12 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
}
}
}
mappingSource = usedPercentageReconcile ? "xpath+percentage" : "xpath";
}
// Extract paragraph index from XPath for direct page lookup via section cache
// Extract paragraph index from XPath for direct page lookup via section cache.
// Done regardless of whether the heavy XPath resolver succeeded — the paragraph
// LUT lookup later (in EpubReaderActivity::NavigationTarget::resolveInto) snaps
// to the precise page, so even without intra resolution we get an exact landing.
uint16_t pIndex = 0;
if (ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(koPos.xpath, pIndex)) {
result.paragraphIndex = pIndex;
@@ -149,15 +212,40 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
}
if (!usedXPathMapping) {
// Heavy XPath resolution failed (typically because heap was too fragmented to
// inflate the spine at sync time). Salvage as much as we can:
// 1) Trust the spine index extracted from the XPath itself — it's purely
// string-derived and always correct when present. Using it preserves
// cross-chapter syncs even when chapter content can't be re-parsed.
// 2) For chapter-start XPaths (ending in ".0" with no paragraph predicate),
// pin intra=0. KOReader's percentage carries small per-DOM rounding that
// would otherwise leak into a spurious intra > 0 via byte-fraction math.
// 3) Otherwise compute intra-spine from KOReader's percentage relative to
// the XPath-derived spine. Falls back to global percentage spine selection
// only when no XPath spine is available.
if (haveXPathSpine) {
result.spineIndex = xpathSpineIndex;
if (isChapterStartXPath(koPos.xpath)) {
resolvedIntraSpineProgress = 0.0f;
mappingSource = "xpath-spine+chapter-start";
LOG_DBG("ProgressMapper", "Chapter-start XPath '%s' on spine=%d, pinning intra=0", koPos.xpath.c_str(),
xpathSpineIndex);
} else {
resolvedIntraSpineProgress = intraSpineFromPercentage(epub, xpathSpineIndex, koPos.percentage);
mappingSource = "xpath-spine+percentage";
LOG_DBG("ProgressMapper", "XPath resolve unavailable for spine=%d; intra from pct=%.3f -> %.3f",
xpathSpineIndex, koPos.percentage, resolvedIntraSpineProgress);
}
} else {
int percentageSpineIndex = -1;
float percentageIntraSpine = -1.0f;
if (!resolveFromPercentage(epub, koPos.percentage, spineCount, percentageSpineIndex, percentageIntraSpine)) {
return result;
}
result.spineIndex = percentageSpineIndex;
resolvedIntraSpineProgress = percentageIntraSpine;
}
}
// Estimate page number within the selected spine item
if (result.spineIndex < epub->getSpineItemsCount()) {
@@ -207,8 +295,6 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.spineIndex, resolvedIntraSpineProgress, result.hasParagraphIndex ? "yes" : "no", result.paragraphIndex,
result.hasListItemIndex ? "yes" : "no", result.listItemIndex);
const char* mappingSource =
usedXPathMapping ? (usedPercentageReconcile ? "xpath+percentage" : "xpath") : "percentage";
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d (%s, exact=%s)",
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber, mappingSource,
xpathExactMatch ? "yes" : "no");
+1 -1
View File
@@ -77,7 +77,7 @@ void ReadingSessionTracker::end() {
LOG_DBG("RST", "Session end doc=%s secs=%u pages=%u prog=%u wall=%lld", docId.c_str(), seconds,
pagesTurnedThisSession, lastKnownProgress, (long long)walltime);
if (seconds > 0 && !docId.empty()) {
if (!docId.empty()) {
READING_STATS.recordSession(docId, title, author, seconds, pagesTurnedThisSession, lastKnownProgress,
static_cast<time_t>(walltime));
if (!READING_STATS.saveToFile()) {
+2 -2
View File
@@ -64,8 +64,8 @@ ReadingStatsStore ReadingStatsStore::instance;
void ReadingStatsStore::recordSession(const std::string& docId, const std::string& title, const std::string& author,
uint32_t sessionSeconds, uint32_t sessionPagesTurned, uint8_t progress,
time_t walltimeEpoch) {
if (docId.empty() || sessionSeconds == 0) {
// Nothing to credit. Title-update-only flows go through a different path.
if (docId.empty()) {
// Title-update-only flows go through a different path.
return;
}
+5 -1
View File
@@ -44,6 +44,10 @@ struct PercentResult {
int percent = 0;
};
struct PrintedPageResult {
std::string label;
};
struct PageResult {
uint32_t page = 0;
};
@@ -78,7 +82,7 @@ struct StarredPageResult {
using ResultVariant =
std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult, PageResult,
SyncResult, NetworkModeResult, FootnoteResult, FilePathResult, StarredPageResult>;
SyncResult, NetworkModeResult, FootnoteResult, FilePathResult, StarredPageResult, PrintedPageResult>;
struct ActivityResult {
bool isCancelled = false;
+13 -2
View File
@@ -1,6 +1,7 @@
#include "SleepActivity.h"
#include <Epub.h>
#include <Epub/Section.h>
#include <Epub/converters/PngToFramebufferConverter.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
@@ -553,6 +554,16 @@ BookOverlayInfo SleepActivity::getBookOverlayInfo(const std::string& bookPath) c
float chapterProgress = static_cast<float>(currentPage) / static_cast<float>(pageCount);
float bookProgress = epub.calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
// Pull the printed-page label (NCX <pageList> / EPUB 3 nav page-list /
// EPUB 2.01 page-map / inline doc-pagebreak) directly from the section
// cache so the sleep overlay can show e.g. "(42)" without instantiating
// a Section + render parameters.
std::string printedPagePrefix;
if (const auto label = Section::getPrintedPageLabelFromCache(
epub.getCachePath() + "/sections", currentSpineIndex, static_cast<uint16_t>(currentPage))) {
printedPagePrefix = *label + " ";
}
const int tocIndex = epub.getTocIndexForSpineIndex(currentSpineIndex);
if (tocIndex != -1) {
const auto tocItem = epub.getTocItem(tocIndex);
@@ -560,13 +571,13 @@ BookOverlayInfo SleepActivity::getBookOverlayInfo(const std::string& bookPath) c
char suffix[64];
snprintf(suffix, sizeof(suffix), tr(STR_OVERLAY_CHAPTER_PAGE_SUFFIX), currentPage + 1, pageCount,
bookProgress);
info.progressSuffix = suffix;
info.progressSuffix = printedPagePrefix + suffix;
info.progressText = info.chapterName + info.progressSuffix;
} else {
char buf[80];
snprintf(buf, sizeof(buf), tr(STR_OVERLAY_READING_PROGRESS), (unsigned long)currentPage + 1,
(unsigned)pageCount, bookProgress);
info.progressText = buf;
info.progressText = printedPagePrefix + buf;
}
} else {
char buf[64];
+234 -43
View File
@@ -19,13 +19,16 @@
#include <esp_system.h>
#include <algorithm>
#include <limits>
#include <memory>
#include <optional>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderFootnotesActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
#include "EpubReaderPrintedPageInputActivity.h"
#include "EpubRenderBenchmarkActivity.h"
#include "FinishedBookActivity.h"
#include "GlobalBookmarkIndex.h"
@@ -48,6 +51,20 @@
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700;
// Parse a printed-page label as a non-negative integer. Returns nullopt for empty strings,
// strings with non-digit characters (e.g. roman "iv"), and overflow. Used both to gate the
// "go to printed page" menu item and to compute min/max for the numeric input.
std::optional<int> parsePrintedPageLabel(const std::string& label) {
if (label.empty()) return std::nullopt;
int value = 0;
for (char c : label) {
if (c < '0' || c > '9') return std::nullopt;
value = value * 10 + (c - '0');
if (value > 999999) return std::nullopt; // sanity
}
return value;
}
// pages per minute, first item is 1 to prevent division by zero if accessed
constexpr int PAGE_TURN_LABELS[] = {1, 1, 3, 6, 12};
@@ -105,6 +122,30 @@ void logReaderMemSnapshot(const char* stage) {
inline void logReaderMemSnapshot(const char*) {}
#endif
// Integrity bisector. Logs at every probe site (unconditional, not gated) and
// fires an ERR when integrity transitions from ok -> fail so we can pinpoint
// which render phase corrupts the heap. Free/contig included so we can see if
// the corruption coincides with a specific allocation pattern. Calling
// heap_caps_check_integrity_all is ~O(blocks) — not free but fine at phase
// boundaries during onEnter / first render.
void logIntegrityProbe(const char* stage) {
static bool sLastOk = true;
const bool ok = heap_caps_check_integrity_all(true);
const uint32_t freeHeap = esp_get_free_heap_size();
const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
if (ok != sLastOk) {
if (ok) {
LOG_DBG("INTG", "[%s] integrity recovered (free=%lu contig=%lu)", stage, freeHeap, contigHeap);
} else {
LOG_ERR("INTG", "[%s] integrity FAIL — corruption introduced here (free=%lu contig=%lu)", stage, freeHeap,
contigHeap);
}
sLastOk = ok;
} else {
LOG_DBG("INTG", "[%s] %s free=%lu contig=%lu", stage, ok ? "ok" : "fail", freeHeap, contigHeap);
}
}
// Tiled grayscale: render each plane band-by-band into a small scratch and
// stream straight to the controller, leaving the BW framebuffer intact so no
// storeBwBuffer / restoreBwBuffer is needed. Controller RAM is re-synced from
@@ -157,15 +198,20 @@ bool runTiledGrayscalePass(GfxRenderer& renderer, const Page& page, int fontId,
}
};
logIntegrityProbe("tiledGray_after_scratchAlloc");
renderPlane(GfxRenderer::GRAYSCALE_LSB, true);
logIntegrityProbe("tiledGray_after_lsbPlane");
renderPlane(GfxRenderer::GRAYSCALE_MSB, false);
logIntegrityProbe("tiledGray_after_msbPlane");
renderer.setRenderMode(GfxRenderer::BW);
renderer.displayGrayBuffer();
logIntegrityProbe("tiledGray_after_displayGrayBuffer");
// BW framebuffer is intact; re-sync controller RAM for the next differential
// page turn directly from it.
renderer.cleanupGrayscaleWithFrameBuffer();
logIntegrityProbe("tiledGray_after_cleanup");
return true;
}
@@ -254,6 +300,7 @@ int getImageOnlyPageYOffset(const Page& page, const int viewportHeight) {
void EpubReaderActivity::onEnter() {
Activity::onEnter();
logReaderMemSnapshot("onEnter_begin");
logIntegrityProbe("onEnter_begin");
// Drop any input events that arrived from the activity that launched us (e.g. a wake-up power
// button hold) before they reach detectPageTurn() — see ReaderUtils::InputDrainGuard.
@@ -273,10 +320,13 @@ void EpubReaderActivity::onEnter() {
epub->setupCacheDir();
logReaderMemSnapshot("onEnter_after_setupCacheDir");
applyPendingSyncSession();
applyPendingBookmarkJump();
logReaderMemSnapshot("onEnter_after_pending_sync");
// Load the persistent baseline (progress.bin) first. Pending session state
// (sync result, bookmark jump) is then overlaid on top — this is the only order
// that lets a Kind::Paragraph / Kind::ListItem navTarget set by applyPendingSyncSession
// survive into render(). The previous order (apply then load) clobbered the LUT
// target with Kind::Page from progress.bin, which is why XPath-precision sync
// silently degraded to the rough page estimate.
FsFile f;
if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6];
@@ -301,6 +351,10 @@ void EpubReaderActivity::onEnter() {
navTarget = NavigationTarget::makePage(0);
}
applyPendingSyncSession();
applyPendingBookmarkJump();
logReaderMemSnapshot("onEnter_after_pending_sync");
if (currentSpineIndex == 0) {
int textSpineIndex = epub->getSpineIndexForTextReference();
if (textSpineIndex != 0) {
@@ -684,6 +738,67 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
});
break;
}
case EpubReaderMenuActivity::MenuAction::GO_TO_PRINTED_PAGE: {
if (!epub) break;
auto entries = epub->loadPrintedPageList();
// Compute the integer label range from parseable entries; non-integer labels are
// ignored (the dialog is numeric-only).
int minLabel = std::numeric_limits<int>::max();
int maxLabel = std::numeric_limits<int>::min();
for (const auto& entry : entries) {
if (const auto n = parsePrintedPageLabel(entry.label)) {
if (*n < minLabel) minLabel = *n;
if (*n > maxLabel) maxLabel = *n;
}
}
if (maxLabel < minLabel) break; // no integer labels — shouldn't happen if menu item was shown
// Pre-fill with the printed page the reader is currently on (or the nearest one before
// it — rendered device pages rarely carry an anchor themselves, but they sit between
// two printed pages, so the closest prior anchor is the "you're here" hint). Falls
// back to the lowest integer label in the book if no prior anchor exists.
int initialValue = minLabel;
if (section) {
if (const auto rawLabel =
section->getNearestPrintedPageLabelAtOrBefore(static_cast<uint16_t>(section->currentPage))) {
if (const auto n = parsePrintedPageLabel(*rawLabel)) {
initialValue = *n;
}
}
}
startActivityForResult(
std::make_unique<EpubReaderPrintedPageInputActivity>(renderer, mappedInput, initialValue, minLabel, maxLabel),
[this, entries = std::move(entries)](const ActivityResult& result) {
if (result.isCancelled) return;
const auto& pick = std::get<PrintedPageResult>(result.data);
// Resolve the typed label back to a (href, anchor) by linear scan. Entries are
// small (typically <500 even for long books) and this fires once per user action.
for (const auto& entry : entries) {
const auto entryLabelValue = parsePrintedPageLabel(entry.label);
const auto pickLabelValue = parsePrintedPageLabel(pick.label);
if (entry.label == pick.label ||
(entryLabelValue && pickLabelValue && *entryLabelValue == *pickLabelValue)) {
const int spineIdx = epub->resolveHrefToSpineIndex(entry.href);
if (spineIdx < 0) {
LOG_DBG("ERS", "printed-page jump: could not resolve spine for href=%s", entry.href.c_str());
return;
}
{
RenderLock lock(*this);
currentSpineIndex = spineIdx;
navTarget =
entry.anchor.empty() ? NavigationTarget::makePage(0) : NavigationTarget::makeAnchor(entry.anchor);
section.reset();
}
requestUpdate();
return;
}
}
LOG_DBG("ERS", "printed-page jump: label '%s' not found in pagelist", pick.label.c_str());
});
break;
}
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
auto p = section->loadPageFromSectionFile();
@@ -1186,7 +1301,9 @@ void EpubReaderActivity::applyPendingSyncSession() {
restorePage = 0;
}
// Build the navigation target from the sync result.
// Build the navigation target from the sync result. For LUT-anchored targets the
// estimated restorePage is plumbed through as fallbackPage so a LUT miss in the
// target spine still lands the user on a sensible page rather than page 0.
NavigationTarget restoreTarget;
if (sync.outcome == KOReaderSyncOutcomeState::APPLIED_REMOTE) {
const int spineCount = epub->getSpineItemsCount();
@@ -1199,11 +1316,11 @@ void EpubReaderActivity::applyPendingSyncSession() {
restorePage = sync.resultPage;
}
if (sync.resultHasListItemIndex) {
restoreTarget = NavigationTarget::makeListItem(sync.resultListItemIndex);
restoreTarget = NavigationTarget::makeListItem(sync.resultListItemIndex, restorePage);
LOG_DBG("ERS", "Applied synced remote position: spine=%d page=%d li[%u]", restoreSpineIndex, restorePage,
sync.resultListItemIndex);
} else if (sync.resultHasParagraphIndex) {
restoreTarget = NavigationTarget::makeParagraph(sync.resultParagraphIndex);
restoreTarget = NavigationTarget::makeParagraph(sync.resultParagraphIndex, restorePage);
LOG_DBG("ERS", "Applied synced remote position: spine=%d page=%d p[%u]", restoreSpineIndex, restorePage,
sync.resultParagraphIndex);
} else {
@@ -1217,21 +1334,26 @@ void EpubReaderActivity::applyPendingSyncSession() {
// sync.totalPagesInSpine is the page count of the local spine at launch time.
// When the restore targets a different spine, that count is meaningless for
// rescaling. Store 0 to disable rescaling; the LUT lookup handles precise positioning.
// rescaling the fallbackPage estimate (which was estimated from cross-spine
// density anyway). Store 0 to disable rescaling — the LUT lookup is the precise
// path, and the cross-spine fallback can't usefully be rescaled here.
const int restorePageCount = (restoreSpineIndex == sync.spineIndex) ? sync.totalPagesInSpine : 0;
restoreTarget.cachedPageCount = restorePageCount;
restoreTarget.cachedSpineIdx = restoreSpineIndex;
// Transient write — the next render's saveProgress() supplies the real percent before the user
// can return to the home screen, so a placeholder 0 here is harmless.
if (writeReaderProgressCache(epub->getCachePath(), restoreSpineIndex, restorePage, restorePageCount, 0)) {
navTarget = restoreTarget;
LOG_DBG("ERS", "Prepared progress.bin for sync restore: spine=%d page=%d/%d", restoreSpineIndex, restorePage,
sync.totalPagesInSpine);
} else {
// Fall back to directly seeding live state if cache write fails.
// Seed live state directly — the previous write-then-reload-from-disk pattern relied
// on progress.bin being read after this function ran, which clobbered the LUT target.
// Live-state seeding is authoritative; the persistent write below is just for crash
// recovery so a power loss before the next saveProgress() doesn't lose the synced
// spine/page. The next render's saveProgress() supplies the real percent before
// the user can return to the home screen.
currentSpineIndex = restoreSpineIndex;
navTarget = restoreTarget;
if (!writeReaderProgressCache(epub->getCachePath(), restoreSpineIndex, restorePage, restorePageCount, 0)) {
LOG_ERR("ERS", "Failed to persist sync restore to progress.bin; live state still seeded");
} else {
LOG_DBG("ERS", "Prepared progress.bin for sync restore: spine=%d page=%d/%d", restoreSpineIndex, restorePage,
sync.totalPagesInSpine);
}
sync.clear();
@@ -1250,14 +1372,13 @@ void EpubReaderActivity::applyPendingBookmarkJump() {
jump.spineIndex = 0;
jump.pageNumber = 0;
}
// Transient write before initializeReader; saveProgress() overwrites with the real percent.
if (writeReaderProgressCache(epub->getCachePath(), jump.spineIndex, jump.pageNumber, 0, 0)) {
navTarget = NavigationTarget::makePage(jump.pageNumber);
navTarget.cachedSpineIdx = jump.spineIndex;
} else {
// Seed live state directly; the persistent write is for crash recovery only.
// saveProgress() on the next render overwrites with the real percent.
currentSpineIndex = jump.spineIndex;
navTarget = NavigationTarget::makePage(jump.pageNumber);
navTarget.cachedSpineIdx = jump.spineIndex;
if (!writeReaderProgressCache(epub->getCachePath(), jump.spineIndex, jump.pageNumber, 0, 0)) {
LOG_ERR("ERS", "Failed to persist bookmark jump to progress.bin; live state still seeded");
}
jump.clear();
APP_STATE.saveToFile();
@@ -1502,58 +1623,95 @@ int EpubReaderActivity::getEffectiveReaderFontId() const {
}
void EpubReaderActivity::NavigationTarget::resolveInto(Section& sec, int spineIndex) const {
if (kind == Kind::LastPage) {
// Resolve to a baseline page first. Each branch records whether it produced a
// precise page (LUT/anchor hit, percent jump, explicit page) or only an estimate.
// The estimate path runs cross-spine rescale + clamp at the end; the precise path
// skips both because LUT pages are already in the target spine's coordinate system.
bool isEstimate = false;
switch (kind) {
case Kind::LastPage: {
sec.currentPage = (sec.pageCount > 0) ? sec.pageCount - 1 : 0;
return;
break;
}
if (kind == Kind::TocIndex) {
if (const auto p = sec.getPageForTocIndex(tocIndex)) sec.currentPage = *p;
return;
case Kind::TocIndex: {
if (const auto p = sec.getPageForTocIndex(tocIndex)) {
sec.currentPage = *p;
}
if (kind == Kind::Anchor) {
break;
}
case Kind::Anchor: {
if (const auto p = sec.getPageForAnchor(anchorStr)) {
sec.currentPage = *p;
LOG_DBG("ERS", "Resolved anchor '%s' -> page %d", anchorStr.c_str(), *p);
} else {
LOG_DBG("ERS", "Anchor '%s' not found in section", anchorStr.c_str());
LOG_DBG("ERS", "Anchor '%s' not found; using fallback page %d", anchorStr.c_str(), fallbackPage);
sec.currentPage = fallbackPage;
isEstimate = true;
}
return;
break;
}
if (kind == Kind::ListItem) {
case Kind::ListItem: {
if (const auto p = sec.getPageForListItemIndex(lutIndex)) {
sec.currentPage = *p;
LOG_DBG("ERS", "Resolved li[%u] -> page %d", lutIndex, *p);
} else if (const auto pp = sec.getPageForParagraphIndex(lutIndex)) {
// Some <li>-anchored XPaths land in books where the LI LUT is empty (no <li>
// inside <body>'s direct children, or all <li>s skipped). Fall back to the
// paragraph LUT — the running indices coincide often enough to help, and
// it's strictly better than dropping back to the estimate.
sec.currentPage = *pp;
LOG_DBG("ERS", "Li LUT miss for li[%u]; paragraph LUT -> page %d", lutIndex, *pp);
} else {
LOG_DBG("ERS", "Li index %u not found in section LUT", lutIndex);
LOG_DBG("ERS", "Li[%u] not in LUT; using fallback page %d", lutIndex, fallbackPage);
sec.currentPage = fallbackPage;
isEstimate = true;
}
return;
break;
}
if (kind == Kind::Paragraph) {
case Kind::Paragraph: {
if (const auto p = sec.getPageForParagraphIndex(lutIndex)) {
sec.currentPage = *p;
LOG_DBG("ERS", "Resolved p[%u] -> page %d", lutIndex, *p);
} else {
LOG_DBG("ERS", "Paragraph LUT miss, using page %d", sec.currentPage);
LOG_DBG("ERS", "Paragraph LUT miss for p[%u]; using fallback page %d", lutIndex, fallbackPage);
sec.currentPage = fallbackPage;
isEstimate = true;
}
return;
break;
}
if (kind == Kind::Percent) {
case Kind::Percent: {
if (sec.pageCount > 0) {
int newPage = static_cast<int>(spineProgress * static_cast<float>(sec.pageCount));
if (newPage >= sec.pageCount) newPage = sec.pageCount - 1;
sec.currentPage = newPage;
}
return;
break;
}
// Kind::Page — apply baseline, then cross-font rescale if we have a cached page count.
case Kind::Page: {
sec.currentPage = page;
if (cachedPageCount > 0 && cachedSpineIdx == spineIndex) {
if (sec.pageCount != cachedPageCount) {
isEstimate = true;
break;
}
}
// Cross-font / cross-spine rescaling: only for estimated pages. cachedPageCount
// is the page count at the time the estimate was made — when it disagrees with
// the section's current page count (reflow / different spine entirely), rescale
// the estimate proportionally before clamping.
if (isEstimate && cachedPageCount > 0 && cachedSpineIdx == spineIndex && sec.pageCount != cachedPageCount) {
const float progress = static_cast<float>(sec.currentPage) / static_cast<float>(cachedPageCount);
sec.currentPage = static_cast<int>(progress * static_cast<float>(sec.pageCount));
}
}
// Safety clamp.
// Safety clamp for all paths — a LUT-derived page is also defensively clamped in
// case the cache is somehow stale.
if (sec.currentPage < 0) {
LOG_DBG("ERS", "Clamping negative page %d to 0 (spine=%d cachedPageCount=%d)", sec.currentPage, spineIndex,
cachedPageCount);
@@ -1635,6 +1793,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!epub) {
return;
}
logIntegrityProbe("render_entry");
const int spineCount = epub->getSpineItemsCount();
if (spineCount <= 0) {
@@ -1779,11 +1938,13 @@ void EpubReaderActivity::render(RenderLock&& lock) {
auto p = section->loadPageFromSectionFile();
section->currentPage = savedPage;
if (p && !p->hasImages()) {
logIntegrityProbe("preRender_before_renderPageContentOnly");
section->currentPage = nextPage;
renderPageContentOnly(*p, orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
section->currentPage = savedPage;
preRenderedPage = {true, currentSpineIndex, nextPage};
LOG_DBG("ERS", "Pre-rendered page %d/%d", nextPage, section->pageCount - 1);
logIntegrityProbe("preRender_after_renderPageContentOnly");
}
}
}
@@ -1866,6 +2027,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
LOG_DBG("ERS", "Cache found, skipping build...");
}
lastRenderStats.sectionLoadMs = millis() - sectionStart;
logIntegrityProbe("render_after_sectionLoad");
if (section->isTruncatedCache() && currentSpineIndex != lastWarnedTruncatedSpineIndex) {
lastWarnedTruncatedSpineIndex = currentSpineIndex;
@@ -1903,6 +2065,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const unsigned long pageLoadStart = millis();
auto p = section->loadPageFromSectionFile();
lastRenderStats.pageLoadMs = millis() - pageLoadStart;
logIntegrityProbe("render_after_pageLoad");
if (!p) {
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
section->clearCache();
@@ -1929,8 +2092,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
truncatedSectionHintRendersRemaining--;
}
LOG_DBG("ERS", "Rendered page in %dms", lastRenderStats.requestRenderMs);
logIntegrityProbe("render_after_renderContents");
}
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
logIntegrityProbe("render_after_silentIndex");
pendingProgressSave.spineIndex = currentSpineIndex;
pendingProgressSave.page = section->currentPage;
pendingProgressSave.pageCount = section->pageCount;
@@ -2010,6 +2175,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginLeft) {
const auto t0 = millis();
logReaderMemSnapshot("render_start");
logIntegrityProbe("renderContents_entry");
auto* fcm = renderer.getFontCacheManager();
fcm->resetStats();
@@ -2025,6 +2191,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const bool warmForceLoad = forceLoadLargeImages || !SETTINGS.largeImagePlaceholder;
page->warmImageCaches(renderer, orientedMarginLeft, contentTop, warmForceLoad);
renderer.clearScreen();
logIntegrityProbe("renderContents_after_warmImages");
logReaderMemSnapshot("prewarm_begin");
@@ -2044,6 +2211,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
LOG_DBG("ERS", "Heap: before=%lu (contig=%lu) after=%lu (contig=%lu) delta=%ld", heapBefore, contigBefore, heapAfter,
contigAfter, (int32_t)heapAfter - (int32_t)heapBefore);
logReaderMemSnapshot("prewarm_end");
logIntegrityProbe("renderContents_after_fontPrewarm");
const bool aaConfigured = getEffectiveTextAntiAliasing();
bool aaEnabledForThisRender = aaConfigured;
@@ -2095,6 +2263,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
fcm->logStats("bw_render");
const auto tBwRender = millis();
logReaderMemSnapshot("after_bw_render");
logIntegrityProbe("renderContents_after_bwRender");
if (imagePageWithAA) {
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
@@ -2142,6 +2311,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
uint32_t tiledGrayMs = 0;
if (aaEnabledForThisRender) {
logReaderMemSnapshot("tiled_gray_begin");
logIntegrityProbe("renderContents_before_tiledGray");
const auto tTiledBegin = millis();
grayscaleDone = runTiledGrayscalePass(renderer, *page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop,
SETTINGS.fastAntiAliasing);
@@ -2149,6 +2319,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
tiledGrayMs = millis() - tTiledBegin;
fcm->logStats("tiled_gray");
logReaderMemSnapshot("tiled_gray_end");
logIntegrityProbe("renderContents_after_tiledGray");
}
}
@@ -2405,7 +2576,13 @@ void EpubReaderActivity::renderStatusBar() const {
const bool isStarred = section && bookmarkStore.has(static_cast<uint16_t>(currentSpineIndex),
static_cast<uint16_t>(section->currentPage));
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, isStarred);
std::string printedPageLabel;
if (section) {
if (const auto label = section->getPrintedPageLabelForPage(static_cast<uint16_t>(section->currentPage))) {
printedPageLabel = *label;
}
}
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, isStarred, printedPageLabel);
lastStatusBarPage = currentPage;
lastStatusBarBattery = SETTINGS.statusBarBattery ? static_cast<int>(powerManager.getBatteryPercentage()) : -1;
@@ -2629,6 +2806,11 @@ void EpubReaderActivity::openQuickOverrides() {
void EpubReaderActivity::openReaderMenu() {
const int currentPage = section ? section->currentPage + 1 : 0;
const int totalPages = section ? section->pageCount : 0;
if (!epub) {
return;
}
float bookProgress = 0.0f;
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
@@ -2637,13 +2819,22 @@ void EpubReaderActivity::openReaderMenu() {
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
const bool isCurrentPageStarred = section && bookmarkStore.has(static_cast<uint16_t>(currentSpineIndex),
static_cast<uint16_t>(section->currentPage));
// Show the "Go to printed page" item only when this book has at least one integer-labelled
// entry in pagelist.bin. Roman-only or empty page lists are excluded — the numeric input
// dialog can't address them anyway.
const auto printedPageList = epub->loadPrintedPageList();
const bool hasPrintedPages = std::any_of(printedPageList.begin(), printedPageList.end(), [](const auto& entry) {
return parsePrintedPageLabel(entry.label).has_value();
});
ReaderUtils::enforceExitFullRefresh(renderer);
startActivityForResult(
std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation,
!currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, bookFontFamilyOverride,
bookSdFontFamilyOverride, bookFontSizeOverride, SETTINGS.textDarkness, getEffectiveBionicReading(),
bookParagraphAlignmentOverride, !bookmarkStore.isEmpty(), isCurrentPageStarred),
bookParagraphAlignmentOverride, !bookmarkStore.isEmpty(), isCurrentPageStarred, hasPrintedPages),
[this](const ActivityResult& result) {
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
+13 -4
View File
@@ -46,9 +46,15 @@ class EpubReaderActivity final : public Activity {
};
std::string anchorStr; // Kind::Anchor; empty for all others
// Cross-font rescaling: page count of this spine at save time.
// Non-zero only for Kind::Page when loaded from progress.bin or written during reflow.
// Non-zero for Kind::Page when loaded from progress.bin or written during reflow.
// Also set for Kind::Paragraph / Kind::ListItem / Kind::Anchor so a LUT miss
// still rescales the estimated fallbackPage instead of stranding at 0.
int cachedPageCount = 0;
int cachedSpineIdx = 0;
// Estimated page used as a baseline before LUT/anchor lookup, and as a fallback
// when the lookup misses. Only meaningful for Kind::Paragraph / Kind::ListItem /
// Kind::Anchor — for Kind::Page the `page` field is the baseline.
int fallbackPage = 0;
NavigationTarget() : kind(Kind::Page), page(0) {}
@@ -64,11 +70,12 @@ class EpubReaderActivity final : public Activity {
t.page = 0;
return t;
}
static NavigationTarget makeAnchor(std::string a) {
static NavigationTarget makeAnchor(std::string a, int fallback = 0) {
NavigationTarget t;
t.kind = Kind::Anchor;
t.page = 0;
t.anchorStr = std::move(a);
t.fallbackPage = fallback;
return t;
}
static NavigationTarget makeTocIndex(int idx) {
@@ -83,16 +90,18 @@ class EpubReaderActivity final : public Activity {
t.spineProgress = sp;
return t;
}
static NavigationTarget makeParagraph(uint16_t i) {
static NavigationTarget makeParagraph(uint16_t i, int fallback = 0) {
NavigationTarget t;
t.kind = Kind::Paragraph;
t.lutIndex = i;
t.fallbackPage = fallback;
return t;
}
static NavigationTarget makeListItem(uint16_t i) {
static NavigationTarget makeListItem(uint16_t i, int fallback = 0) {
NavigationTarget t;
t.kind = Kind::ListItem;
t.lutIndex = i;
t.fallbackPage = fallback;
return t;
}
@@ -40,7 +40,8 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(
const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride,
const int8_t initialFontFamilyOverride, const std::string& initialSdFontFamilyOverride,
const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, const bool initialBionicReadingOverride,
const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages, const bool isCurrentPageStarred)
const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages, const bool isCurrentPageStarred,
const bool hasPrintedPages)
: MenuListActivity("EpubReaderMenu", renderer, mappedInput),
currentPageStarred(isCurrentPageStarred),
pendingOrientation(currentOrientation),
@@ -56,16 +57,19 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent) {
buildMenuItems(hasFootnotes, hasStarredPages);
buildMenuItems(hasFootnotes, hasStarredPages, hasPrintedPages);
}
void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPages) {
void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPages, bool hasPrintedPages) {
menuItems.reserve(20);
// --- Navigation ---
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_NAVIGATION));
menuItems.push_back(SettingInfo::Action(StrId::STR_SELECT_CHAPTER, SettingAction::None));
menuItems.push_back(SettingInfo::Action(StrId::STR_GO_TO_PERCENT, SettingAction::None));
if (hasPrintedPages) {
menuItems.push_back(SettingInfo::Action(StrId::STR_GO_TO_PRINTED_PAGE, SettingAction::None));
}
// Bookmarks, footnotes
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_BOOKMARKS));
@@ -277,6 +281,8 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId
return MenuAction::SELECT_CHAPTER;
case StrId::STR_GO_TO_PERCENT:
return MenuAction::GO_TO_PERCENT;
case StrId::STR_GO_TO_PRINTED_PAGE:
return MenuAction::GO_TO_PRINTED_PAGE;
case StrId::STR_STARRED_PAGES:
return MenuAction::STARRED_PAGES;
case StrId::STR_STAR_PAGE:
@@ -19,6 +19,7 @@ class EpubReaderMenuActivity final : public MenuListActivity {
IMAGE_RENDERING,
TEXT_DARKNESS,
GO_TO_PERCENT,
GO_TO_PRINTED_PAGE,
AUTO_PAGE_TURN,
ROTATE_SCREEN,
SCREENSHOT,
@@ -42,13 +43,13 @@ class EpubReaderMenuActivity final : public MenuListActivity {
const std::string& initialSdFontFamilyOverride, const int8_t initialFontSizeOverride,
const uint8_t initialTextDarkness, const bool initialBionicReadingOverride,
const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages,
const bool isCurrentPageStarred);
const bool isCurrentPageStarred, const bool hasPrintedPages);
void onEnter() override;
void render(RenderLock&&) override;
private:
void buildMenuItems(bool hasFootnotes, bool hasStarredPages);
void buildMenuItems(bool hasFootnotes, bool hasStarredPages, bool hasPrintedPages);
bool currentPageStarred = false;
void finishWithAction(MenuAction action);
@@ -0,0 +1,182 @@
#include "EpubReaderPrintedPageInputActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include "ButtonEventManager.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
int EpubReaderPrintedPageInputActivity::powTen(int exponent) {
int result = 1;
for (int i = 0; i < exponent; i++) result *= 10;
return result;
}
int EpubReaderPrintedPageInputActivity::digitCount() const {
int n = (value > 0) ? value : 1;
int count = 0;
while (n > 0) {
count++;
n /= 10;
}
return count;
}
int EpubReaderPrintedPageInputActivity::maxCursorDigit() const {
// Bound the reachable cursor position by maxValue's digit count, not the current value's.
// This lets the user step into "empty" higher digits (e.g. cursor sits over the tens
// place while value is still 1, and pressing Up turns it into 11). Without this you
// could never grow a 1 into a 3-digit number without first single-pressing into double
// digits, which defeats the point of having a digit cursor.
int n = (maxValue > 0) ? maxValue : 1;
int count = 0;
while (n > 0) {
count++;
n /= 10;
}
return count - 1; // 0-based: ones=0, tens=1, hundreds=2, ...
}
void EpubReaderPrintedPageInputActivity::clampValue() {
if (value < minValue) value = minValue;
if (value > maxValue) value = maxValue;
}
void EpubReaderPrintedPageInputActivity::adjustDigit(int delta) {
value += delta * powTen(cursorDigit);
clampValue();
// Don't pull the cursor in toward the new digit count — leave it where the user put it.
// The cursor is a position the user navigated to, not a property of the value.
if (cursorDigit > maxCursorDigit()) cursorDigit = maxCursorDigit();
requestUpdate();
}
void EpubReaderPrintedPageInputActivity::adjustDigitTimes(int multiplier, int sign) {
// Used by the double-click handler: step is multiplier × 10^cursorDigit (e.g. 10 at the
// ones place, 100 at the tens place). Sign is +1 or -1.
value += sign * multiplier * powTen(cursorDigit);
clampValue();
if (cursorDigit > maxCursorDigit()) cursorDigit = maxCursorDigit();
requestUpdate();
}
void EpubReaderPrintedPageInputActivity::moveCursor(int delta) {
cursorDigit += delta;
if (cursorDigit < 0) cursorDigit = 0;
if (cursorDigit > maxCursorDigit()) cursorDigit = maxCursorDigit();
requestUpdate();
}
void EpubReaderPrintedPageInputActivity::onEnter() {
Activity::onEnter();
// Force the FSM to wait for the double-click window on Up/Down so we can distinguish
// Short (±1) from Double (±10). Adds ~300ms latency to single Up/Down presses; that's
// the price for the larger step. Back/Confirm/Left/Right stay on the immediate
// wasPressed path — no latency for navigation.
buttonEvents.forceDoubleAction(MappedInputManager::Button::Up, true);
buttonEvents.forceDoubleAction(MappedInputManager::Button::Down, true);
requestUpdate();
}
void EpubReaderPrintedPageInputActivity::onExit() {
buttonEvents.forceDoubleAction(MappedInputManager::Button::Up, false);
buttonEvents.forceDoubleAction(MappedInputManager::Button::Down, false);
Activity::onExit();
}
void EpubReaderPrintedPageInputActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
setResult(PrintedPageResult{std::to_string(value)});
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
moveCursor(1); // cursor moves toward higher digits on Left, matching screen layout
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
moveCursor(-1);
return;
}
// Up/Down come through the FSM-backed event queue so we can react to Short vs Double.
// Short = ±1, Double = ±10. Both Up and PageBack map to the same hardware button; the
// FSM emits an event for each logical button, but we only handle the Up/Down variants
// here. The global dispatcher consumes PageBack/PageForward variants (they're configured
// as page-turn actions in the reader) and dispatch is a no-op while this dialog is
// current, so they're effectively swallowed.
ButtonEventManager::ButtonEvent ev;
while (buttonEvents.consumeEvent(ev)) {
if (ev.button == MappedInputManager::Button::Up) {
if (ev.type == ButtonEventManager::PressType::Short) {
adjustDigit(1);
} else if (ev.type == ButtonEventManager::PressType::Double) {
adjustDigitTimes(10, 1);
}
} else if (ev.button == MappedInputManager::Button::Down) {
if (ev.type == ButtonEventManager::PressType::Short) {
adjustDigit(-1);
} else if (ev.type == ButtonEventManager::PressType::Double) {
adjustDigitTimes(10, -1);
}
}
// Other queued events (PageBack/PageForward variants and any stray) are discarded;
// the wasPressed path above already handled Back/Confirm/Left/Right.
}
}
void EpubReaderPrintedPageInputActivity::render(RenderLock&&) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_GO_TO_PRINTED_PAGE), true, EpdFontFamily::BOLD);
// Big centred numeric value with an underline under the active digit position.
// When the cursor sits over a digit position past the current value (e.g. cursor at the
// tens place while value is still 1), we pad the displayed number with leading "·" dots
// so the underline can mark the empty position it'll grow into on the next Up press.
const std::string rawValueText = std::to_string(value);
const int visibleDigits = std::max(digitCount(), cursorDigit + 1);
std::string valueText;
for (int i = 0; i < visibleDigits - digitCount(); i++) valueText += "0"; // leading zeros
valueText += rawValueText;
const int valueY = 110;
renderer.drawCenteredText(UI_12_FONT_ID, valueY, valueText.c_str(), true, EpdFontFamily::BOLD);
// Place the underline under the active digit. Width-based positioning approximates a
// monospaced grid using the total rendered width / digit count; small visual mismatch on
// proportional fonts is acceptable for a one-character indicator.
const int totalWidth = renderer.getTextWidth(UI_12_FONT_ID, valueText.c_str());
const int screenWidth = renderer.getScreenWidth();
const int startX = (screenWidth - totalWidth) / 2;
const int digitIndexFromLeft = visibleDigits - 1 - cursorDigit; // 0-based, from the left
const int avgDigitWidth = (visibleDigits > 0) ? totalWidth / visibleDigits : 0;
const int underlineX = startX + digitIndexFromLeft * avgDigitWidth;
const int underlineWidth = avgDigitWidth;
const int underlineY = valueY + renderer.getLineHeight(UI_12_FONT_ID) + 2;
renderer.fillRect(underlineX, underlineY, underlineWidth, 3, true);
// Range hint underneath: "Range: 1 - 305"
char rangeBuf[48];
snprintf(rangeBuf, sizeof(rangeBuf), tr(STR_GO_TO_PRINTED_PAGE_RANGE), (unsigned)minValue, (unsigned)maxValue);
renderer.drawCenteredText(SMALL_FONT_ID, underlineY + 25, rangeBuf, true);
// Step hint.
renderer.drawCenteredText(SMALL_FONT_ID, underlineY + 50, tr(STR_GO_TO_PRINTED_PAGE_HINT), true);
// Button hints.
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,46 @@
#pragma once
#include "MappedInputManager.h"
#include "activities/Activity.h"
// Numeric input dialog for "jump to printed page".
// User adjusts a single integer (mirroring the printed-page label as shown in the book).
// Up/Down change the digit under the cursor by ±1 (single) or ±10 (double-click).
// Left/Right move the cursor between digits; Confirm returns the typed string.
// Books with non-integer labels (roman numerals, etc.) are not addressable via this dialog;
// the menu item is hidden if the book has no integer-parseable printed-page labels.
class EpubReaderPrintedPageInputActivity final : public Activity {
public:
explicit EpubReaderPrintedPageInputActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int initialValue,
int minValue, int maxValue)
: Activity("EpubReaderPrintedPageInput", renderer, mappedInput),
value(initialValue),
minValue(minValue),
maxValue(maxValue) {
clampValue();
cursorDigit = 0; // ones place
}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
int value = 0;
int minValue = 1;
int maxValue = 1;
int cursorDigit = 0; // 0 = ones, 1 = tens, 2 = hundreds, ...
void clampValue();
void adjustDigit(int delta);
void adjustDigitTimes(int multiplier, int sign); // delta = sign * multiplier * 10^cursorDigit
void moveCursor(int delta);
static int powTen(int exponent);
int digitCount() const;
// Highest cursor position the user can reach: one less than the digit count of maxValue.
// Lets the user move the cursor "past" the current value to higher digit positions that
// don't exist yet, so they can grow the number quickly (e.g. start at 1, move cursor left,
// press Up to make 11, etc.) without single-pressing dozens of times.
int maxCursorDigit() const;
};
+25 -1
View File
@@ -163,8 +163,10 @@ void KOReaderSyncActivity::performFetchAndCompare() {
// avoid a second TLS handshake under fragmented heap.
KOReaderSyncClient::beginPersistentSession();
logSyncMemSnapshot("before_getProgress");
// Fetch remote progress
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
logSyncMemSnapshot("after_getProgress");
if (result == KOReaderSyncClient::NOT_FOUND) {
if (syncIntent == KOReaderSyncIntentState::PULL_REMOTE) {
@@ -278,7 +280,16 @@ void KOReaderSyncActivity::performFetchAndCompare() {
// still useful for manual conflict decisions.
// Pre-map remote progress now so compare UI always shows concrete chapter/
// page data. The mapped result is cached and reused if Apply is chosen.
if (!ensureRemotePositionMapped(false)) {
// closeSessionBeforeMapping=true tears down the warmed TLS session before
// reverse XPath mapping so the 32 KB inflate ring buffer can allocate.
// Trade-off: if the user later picks Upload, we eat one extra TLS handshake
// (~1.7s). That's the less-common choice — Apply is what users usually want —
// and silent inflate failures here previously caused syncs to land on the
// wrong page. See logSyncMemSnapshot("after_getProgress") for the heap drop
// a held-open session causes (~36 KB contig consumed by esp_http_client
// state and response buffer that aren't released until cleanup).
logSyncMemSnapshot("before_compare_map");
if (!ensureRemotePositionMapped(true)) {
{
RenderLock lock(*this);
state = SYNC_FAILED;
@@ -704,11 +715,19 @@ bool KOReaderSyncActivity::ensureRemotePositionMapped(const bool closeSessionBef
return true;
}
// Diagnostic snapshots around each phase of remote->local mapping. The reverse
// XPath mapper needs a 32 KB contiguous block for the inflate ring buffer; if
// that allocation fails we silently degrade to percentage-only mapping and
// round-trip accuracy suffers. Snapshots here let us see exactly which phase
// fragments the heap so the fix can target the actual culprit.
logSyncMemSnapshot("ensureRemoteMap_entry");
// Mapping remote->local can trigger EPUB inflate work. For apply/pull paths,
// release HTTP/TLS first to maximize heap headroom. Compare pre-map keeps
// the warmed session alive so Upload can reuse it without a fresh handshake.
if (closeSessionBeforeMapping) {
KOReaderSyncClient::endPersistentSession();
logSyncMemSnapshot("ensureRemoteMap_after_endSession");
}
{
@@ -716,12 +735,17 @@ bool KOReaderSyncActivity::ensureRemotePositionMapped(const bool closeSessionBef
statusMessage = tr(STR_MAPPING_REMOTE);
}
requestUpdateAndWait();
logSyncMemSnapshot("ensureRemoteMap_after_statusUpdate");
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
if (!ensureEpubLoadedForMapping()) {
return false;
}
logSyncMemSnapshot("ensureRemoteMap_after_epubLoad");
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine);
logSyncMemSnapshot("ensureRemoteMap_after_toCrossPoint");
computeRemoteChapter();
releaseEpubForMapping();
hasRemoteProgress = true;
+21 -8
View File
@@ -764,8 +764,8 @@ 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 bool isStarred) const {
const int pageCount, std::string title, const int paddingBottom, const bool isStarred,
const std::string& printedPageLabel) const {
auto metrics = UITheme::getInstance().getMetrics();
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
@@ -794,7 +794,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
const bool hasProgressText = SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount;
const bool hasStatusItems = hasProgressText || SETTINGS.statusBarBattery || !title.empty() ||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE ||
(SETTINGS.useClock && SETTINGS.statusBarClock);
(SETTINGS.useClock && SETTINGS.statusBarClock) || !printedPageLabel.empty();
if (!hasStatusItems) {
return;
}
@@ -812,9 +812,14 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
: screenHeight - orientedMarginBottom - paddingBottom - adjacentProgressHeight -
statusItemsHeight + 4;
int progressTextWidth = 0;
const int printedLabelWidth =
printedPageLabel.empty() ? 0 : renderer.getTextWidth(SMALL_FONT_ID, printedPageLabel.c_str());
const int printedLabelGap = printedLabelWidth > 0 && hasProgressText ? 8 : 0;
if (hasProgressText) {
// Right aligned text for progress counter
// Right-aligned device page counter / progress percentage. The printed-page label, if any,
// is drawn to the LEFT of this counter as a parenthesised hint — the device counter on the
// right always reflects spine pagination.
char progressStr[32];
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) {
@@ -825,10 +830,18 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount);
}
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
renderer.drawText(SMALL_FONT_ID,
screenWidth - metrics.statusBarHorizontalMargin - orientedMarginRight - progressTextWidth, textY,
progressStr);
const int progressStrWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
progressTextWidth = progressStrWidth + printedLabelGap + printedLabelWidth;
const int textX = screenWidth - metrics.statusBarHorizontalMargin - orientedMarginRight - progressStrWidth;
renderer.drawText(SMALL_FONT_ID, textX, textY, progressStr);
if (printedLabelWidth > 0) {
renderer.drawText(SMALL_FONT_ID, textX - printedLabelGap - printedLabelWidth, textY, printedPageLabel.c_str());
}
} else if (printedLabelWidth > 0) {
progressTextWidth = printedLabelWidth;
const int textX = screenWidth - metrics.statusBarHorizontalMargin - orientedMarginRight - printedLabelWidth;
renderer.drawText(SMALL_FONT_ID, textX, textY, printedPageLabel.c_str());
}
// Draw Battery
+1 -1
View File
@@ -160,7 +160,7 @@ class BaseTheme {
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
const int pageCount, std::string title, const int paddingBottom = 0,
const bool isStarred = false) const;
const bool isStarred = false, const std::string& printedPageLabel = std::string()) const;
virtual 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;
+6
View File
@@ -16,6 +16,7 @@
#include <SPI.h>
#include <WiFi.h>
#include <builtinFonts/all.h>
#include <esp_heap_caps.h>
#include <esp_ota_ops.h>
#include <cstring>
@@ -28,6 +29,7 @@
#include "KOReaderCredentialStore.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "ReadingSessionTracker.h"
#include "ReadingStats.h"
#include "RecentBooksStore.h"
#include "SdCardFontSystem.h"
@@ -154,6 +156,9 @@ static bool deepSleepInProgress = false;
void silentRestart() {
if (deepSleepInProgress) return; // sleeping supersedes the heap-defrag reboot
// ESP.restart() bypasses activity onExit(), so flush any in-flight reading
// session manually — otherwise a heap-defrag reboot mid-read loses the session.
globalReadingSessionTracker().end();
silentRebootTarget = SILENT_REBOOT_TARGET_HOME;
silentRebootMagic = SILENT_REBOOT_MAGIC;
LOG_DBG("MAIN", "Silent restart (target=home)");
@@ -163,6 +168,7 @@ void silentRestart() {
void silentRestartToReader() {
if (deepSleepInProgress) return; // sleeping supersedes the heap-defrag reboot
globalReadingSessionTracker().end();
silentRebootTarget = SILENT_REBOOT_TARGET_READER;
silentRebootMagic = SILENT_REBOOT_MAGIC;
LOG_DBG("MAIN", "Silent restart (target=reader)");