feat: footnote anchor navigation (#1245)

## Summary: Enable footnote anchor navigation in EPUB reader

This PR extracts the core anchor-to-page mapping mechanism from PR #1143
(TOC fragment navigation) to provide immediate footnote navigation
support. By merging this focused subset first, users get a complete
footnote experience now while simplifying the eventual review and merge
of the full #1143 PR.

  ---

## What this extracts from PR #1143

PR #1143 implements comprehensive TOC fragment navigation for EPUBs with
multi-chapter spine files. This PR takes only the anchor resolution
infrastructure:

- Anchor-to-page mapping in section cache: During page layout,
ChapterHtmlSlimParser records which page each HTML id attribute lands
on, serializing the map into the .bin cache file.
- Anchor resolution in `EpubReaderActivity`: When navigating to a
footnote link with a fragment (e.g., `chapter2.xhtml#note1`), the reader
resolves the anchor to a page number and jumps directly to it.
- Section file format change: Bumped to version 15, adds anchor map
offset in header.

  ---

## Simplified scope vs. PR #1143

To minimize conflicts and complexity, this PR differs from #1143 in key
ways:

* **Anchors tracked**
  * **Origin:** Only TOC anchors (passed via `std::set`)
  * **This branch:** All `id` attributes

* **Page breaks**
  * **Origin**: Forces new page at TOC chapter boundaries
  * **This branch:** None — natural flow

* **TOC integration**
  * **Origin**: `tocBoundaries`, `getTocIndexForPage()`, chapter skip
  * **This branch:** None — just footnote links

* **Bug fix**
  * **This branch:** Fixed anchor page off-by-1/2 bug


The anchor recording bug (recording page number before `makePages()`
flushes previous block) was identified and fixed during this extraction.
The fix uses a deferred `pendingAnchorId` pattern that records the
anchor after page completion.

  ---

## Positioning for future merge

Changes are structured to minimize conflicts when #1143 eventually
merges:

- `ChapterHtmlSlimParser.cpp` `startElement()`: Both branches rewrite
the same if `(!idAttr.empty())` block. The merged version will combine
both approaches (TOC anchors get page breaks + immediate recording;
footnote anchors get deferred recording).
- `EpubReaderActivity.cpp` `render()`: The `pendingAnchor` resolution
block is positioned at the exact same insertion point where #1143 places
its `pendingTocIndex` block (line 596, right after `nextPageNumber`
assignment). During merge, both blocks will sit side-by-side.
   
  ---

## Why merge separately?

1. Immediate user value: Footnote navigation works now without waiting
for the full TOC overhaul
   2. Easier review: ~100 lines vs. 500+ lines in #1143 
3. Bug fix included: The page recording bug is fixed here and will carry
into #1143
4. Minimal conflicts: Structured for clean merge — both PRs touch the
same files but in complementary ways
---

### AI Usage

Did you use AI tools to help write this code? _**< YES >**_ Done by
Claude Opus 4.6
This commit is contained in:
Uri Tauber
2026-03-06 21:10:45 +03:00
committed by GitHub
parent 1ae9529639
commit 8fbb37b359
6 changed files with 106 additions and 10 deletions
@@ -133,11 +133,21 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
// This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the
// div's margin should be preserved, even though it has no direct text content.
currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle));
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
return;
}
makePages();
}
// Record deferred anchor after previous block is flushed
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
wordsExtractedInBlock = 0;
}
@@ -151,7 +161,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return;
}
// Extract class and style attributes for CSS processing
// Extract class, style, and id attributes
std::string classAttr;
std::string styleAttr;
if (atts != nullptr) {
@@ -160,6 +170,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
classAttr = atts[i + 1];
} else if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
} else if (strcmp(atts[i], "id") == 0) {
// Defer recording until startNewTextBlock, after previous block is flushed to pages
self->pendingAnchorId = atts[i + 1];
}
}
}
@@ -374,6 +387,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (self->currentPage && !self->currentPage->elements.empty() &&
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
self->completePageFn(std::move(self->currentPage));
self->completedPageCount++;
self->currentPage.reset(new Page());
if (!self->currentPage) {
LOG_ERR("EHP", "Failed to create new page");
@@ -990,7 +1004,12 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
// Process last page if there is still text
if (currentTextBlock) {
makePages();
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset();
currentTextBlock.reset();
}
@@ -1003,6 +1022,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
if (currentPageNextY + lineHeight > viewportHeight) {
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}