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:
Uri Tauber
2026-02-26 16:47:34 +02:00
committed by GitHub
parent 451774ddf8
commit 30d8a8d011
15 changed files with 481 additions and 22 deletions

View File

@@ -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();
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <Epub.h>
#include <Epub/FootnoteEntry.h>
#include <Epub/Section.h>
#include "EpubReaderMenuActivity.h"
@@ -25,6 +26,16 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
// Footnote support
std::vector<FootnoteEntry> currentPageFootnotes;
struct SavedPosition {
int spineIndex;
int pageNumber;
};
static constexpr int MAX_FOOTNOTE_DEPTH = 3;
SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
int footnoteDepth = 0;
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;
@@ -35,6 +46,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
void applyOrientation(uint8_t orientation);
// Footnote navigation
void navigateToHref(const char* href, bool savePosition = false);
void restoreSavedPosition();
public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)

View File

@@ -0,0 +1,95 @@
#include "EpubReaderFootnotesActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void EpubReaderFootnotesActivity::onEnter() {
ActivityWithSubactivity::onEnter();
selectedIndex = 0;
requestUpdate();
}
void EpubReaderFootnotesActivity::onExit() { ActivityWithSubactivity::onExit(); }
void EpubReaderFootnotesActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
onSelectFootnote(footnotes[selectedIndex].href);
}
return;
}
buttonNavigator.onNext([this] {
if (!footnotes.empty()) {
selectedIndex = (selectedIndex + 1) % footnotes.size();
requestUpdate();
}
});
buttonNavigator.onPrevious([this] {
if (!footnotes.empty()) {
selectedIndex = (selectedIndex - 1 + footnotes.size()) % footnotes.size();
requestUpdate();
}
});
}
void EpubReaderFootnotesActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_FOOTNOTES), true, EpdFontFamily::BOLD);
if (footnotes.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, 90, tr(STR_NO_FOOTNOTES));
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
constexpr int startY = 50;
constexpr int lineHeight = 36;
const int screenWidth = renderer.getScreenWidth();
constexpr int marginLeft = 20;
const int visibleCount = std::max(1, (renderer.getScreenHeight() - startY) / lineHeight);
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
if (selectedIndex >= scrollOffset + visibleCount) scrollOffset = selectedIndex - visibleCount + 1;
for (int i = scrollOffset; i < static_cast<int>(footnotes.size()) && i < scrollOffset + visibleCount; i++) {
const int y = startY + (i - scrollOffset) * lineHeight;
const bool isSelected = (i == selectedIndex);
if (isSelected) {
renderer.fillRect(0, y, screenWidth, lineHeight, true);
}
// Show footnote number and abbreviated href
std::string label = footnotes[i].number;
if (label.empty()) {
label = tr(STR_LINK);
}
renderer.drawText(UI_10_FONT_ID, marginLeft, y + 4, label.c_str(), !isSelected);
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include <Epub/FootnoteEntry.h>
#include <cstring>
#include <functional>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderFootnotesActivity final : public ActivityWithSubactivity {
public:
explicit EpubReaderFootnotesActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::vector<FootnoteEntry>& footnotes,
const std::function<void()>& onGoBack,
const std::function<void(const char*)>& onSelectFootnote)
: ActivityWithSubactivity("EpubReaderFootnotes", renderer, mappedInput),
footnotes(footnotes),
onGoBack(onGoBack),
onSelectFootnote(onSelectFootnote) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
const std::vector<FootnoteEntry>& footnotes;
const std::function<void()> onGoBack;
const std::function<void(const char*)> onSelectFootnote;
int selectedIndex = 0;
int scrollOffset = 0;
ButtonNavigator buttonNavigator;
};

View File

@@ -7,6 +7,38 @@
#include "components/UITheme.h"
#include "fontIds.h"
EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& title, const int currentPage, const int totalPages,
const int bookProgressPercent, const uint8_t currentOrientation,
const bool hasFootnotes, const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasFootnotes)),
title(title),
pendingOrientation(currentOrientation),
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent),
onBack(onBack),
onAction(onAction) {}
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
std::vector<MenuItem> items;
items.reserve(9);
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
if (hasFootnotes) {
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
}
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
items.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON});
items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR});
items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON});
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
return items;
}
void EpubReaderMenuActivity::onEnter() {
ActivityWithSubactivity::onEnter();
requestUpdate();

View File

@@ -14,6 +14,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
// Menu actions available from the reader menu.
enum class MenuAction {
SELECT_CHAPTER,
FOOTNOTES,
GO_TO_PERCENT,
ROTATE_SCREEN,
SCREENSHOT,
@@ -25,16 +26,9 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
title(title),
pendingOrientation(currentOrientation),
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent),
onBack(onBack),
onAction(onAction) {}
const uint8_t currentOrientation, const bool hasFootnotes,
const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction);
void onEnter() override;
void onExit() override;
@@ -47,15 +41,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
StrId labelId;
};
// Fixed menu layout (order matters for up/down navigation).
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER},
{MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION},
{MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT},
{MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON},
{MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR},
{MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON},
{MenuAction::SYNC, StrId::STR_SYNC_PROGRESS},
{MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}};
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes);
// Fixed menu layout
const std::vector<MenuItem> menuItems;
int selectedIndex = 0;
ButtonNavigator buttonNavigator;