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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
95
src/activities/reader/EpubReaderFootnotesActivity.cpp
Normal file
95
src/activities/reader/EpubReaderFootnotesActivity.cpp
Normal 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();
|
||||
}
|
||||
35
src/activities/reader/EpubReaderFootnotesActivity.h
Normal file
35
src/activities/reader/EpubReaderFootnotesActivity.h
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user