feat: slim footnotes support (#1031)
## Summary **What is the goal of this PR?** Implement support for footnotes in epub files. It is based on #553, but simplified — removed the parts which complicated the code and burden the CPU/RAM. This version supports basic footnotes and lets the user jump from location to location inside the epub. **What changes are included?** - `FootnoteEntry` struct — A small POD struct (number[24], href[64]) shared between parser, page storage, and UI. - Parser: `<a href>` detection (`ChapterHtmlSlimParser`) — During a single parsing pass, internal epub links are detected and collected as footnotes. The link text is underlined to hint navigability. Bracket/whitespace normalization is applied to the display label (e.g. [1] → 1). - Footnote-to-page assignment (`ChapterHtmlSlimParser`, `Page`) — Footnotes are attached to the exact page where their anchor word appears, tracked via a cumulative word counter during layout, surviving paragraph splits and the 750-word mid-paragraph safety flush. - Page serialization (`Page`, `Section`) — Footnotes are serialized/deserialized per page (max 16 per page). Section cache version bumped to 14 to force a clean rebuild. - Href → spine resolution (`Epub`) — `resolveHrefToSpineIndex()` maps an href (e.g. `chapter2.xhtml#note1`) to its spine index by filename matching. - Footnotes menu + activity (`EpubReaderMenuActivity`, `EpubReaderFootnotesActivity`) — A new "Footnotes" entry in the reader menu lists all footnote links found on the current page. The user scrolls and selects to navigate. - Navigate & restore (`EpubReaderActivity`) — `navigateToHref()` saves the current spine index and page number, then jumps to the target. The Back button restores the saved position when the user is done reading the footnote. **Additional Context** **What was removed vs #553:** virtual spine items (`addVirtualSpineItem`, `isVirtualSpineItem`), two-pass parsing, `<aside>` content extraction to temp HTML files, `<p class="note">` paragraph note extraction, `replaceHtmlEntities` (master already has `lookupHtmlEntity`), `footnotePages` / `buildFilteredChapterList`, `noterefCallback` / `Noteref` struct, and the stack size increase from 8 KB to 24 KB (not needed without two-pass parsing and virtual file I/O on the render task). **Performance:** Single-pass parsing. No new heap allocations in the hot path — footnote text is collected into fixed stack buffers (char[24], char[64]). Active runtime memory is ~2.8 KB worst-case (one page × 16 footnotes × 88 bytes, mirrored in `currentPageFootnotes`). Flash usage is unchanged at 97.4%; RAM stays at 31%. **Known limitations:** When clicking a footnote, it jumps to the start of the HTML file instead of the specific anchor. This could be problematic for books that don't have separate files for each footnote. (no element-id-to-page mapping yet - will be another PR soon). --- ### AI Usage Did you use AI tools to help write this code? _**< PARTIALLY>**_ Claude Opus 4.6 was used to do most of the migration, I checked manually its work, and fixed some stuff, but I haven't review all the changes yet, so feedback is welcomed. --------- Co-authored-by: Arthur Tazhitdinov <lisnake@gmail.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderFootnotesActivity.h"
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderSyncActivity.h"
|
||||
@@ -177,7 +178,8 @@ void EpubReaderActivity::loop() {
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderMenuActivity(
|
||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
SETTINGS.orientation, !currentPageFootnotes.empty(),
|
||||
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||
}
|
||||
|
||||
@@ -187,8 +189,12 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press BACK goes directly to home
|
||||
// Short press BACK goes directly to home (or restores position if viewing footnote)
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
||||
if (footnoteDepth > 0) {
|
||||
restoreSavedPosition();
|
||||
return;
|
||||
}
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
@@ -379,6 +385,23 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::FOOTNOTES: {
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderFootnotesActivity(
|
||||
this->renderer, this->mappedInput, currentPageFootnotes,
|
||||
[this] {
|
||||
// Go back from footnotes list
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const char* href) {
|
||||
// Navigate to selected footnote
|
||||
navigateToHref(href, true);
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: {
|
||||
// Launch the slider-based percent selector and return here on confirm/cancel.
|
||||
float bookProgress = 0.0f;
|
||||
@@ -641,6 +664,10 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
// TODO: prevent infinite loop if the page keeps failing to load for some reason
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect footnotes from the loaded page
|
||||
currentPageFootnotes = std::move(p->footnotes);
|
||||
|
||||
const auto start = millis();
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
@@ -757,3 +784,57 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title);
|
||||
}
|
||||
|
||||
void EpubReaderActivity::navigateToHref(const char* href, const bool savePosition) {
|
||||
if (!epub || !href) return;
|
||||
|
||||
// Push current position onto saved stack
|
||||
if (savePosition && section && footnoteDepth < MAX_FOOTNOTE_DEPTH) {
|
||||
savedPositions[footnoteDepth] = {currentSpineIndex, section->currentPage};
|
||||
footnoteDepth++;
|
||||
LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex, section->currentPage);
|
||||
}
|
||||
|
||||
std::string hrefStr(href);
|
||||
|
||||
// Check for same-file anchor reference (#anchor only)
|
||||
bool sameFile = !hrefStr.empty() && hrefStr[0] == '#';
|
||||
|
||||
int targetSpineIndex;
|
||||
if (sameFile) {
|
||||
// Same file — navigate to page 0 of current spine item
|
||||
targetSpineIndex = currentSpineIndex;
|
||||
} else {
|
||||
targetSpineIndex = epub->resolveHrefToSpineIndex(hrefStr);
|
||||
}
|
||||
|
||||
if (targetSpineIndex < 0) {
|
||||
LOG_DBG("ERS", "Could not resolve href: %s", href);
|
||||
if (savePosition && footnoteDepth > 0) footnoteDepth--; // undo push
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
currentSpineIndex = targetSpineIndex;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
}
|
||||
requestUpdate();
|
||||
LOG_DBG("ERS", "Navigated to spine %d for href: %s", targetSpineIndex, href);
|
||||
}
|
||||
|
||||
void EpubReaderActivity::restoreSavedPosition() {
|
||||
if (footnoteDepth <= 0) return;
|
||||
footnoteDepth--;
|
||||
const auto& pos = savedPositions[footnoteDepth];
|
||||
LOG_DBG("ERS", "Restoring position [%d]: spine %d, page %d", footnoteDepth, pos.spineIndex, pos.pageNumber);
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
currentSpineIndex = pos.spineIndex;
|
||||
nextPageNumber = pos.pageNumber;
|
||||
section.reset();
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user