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

@@ -858,3 +858,30 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize; const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
return totalProgress / static_cast<float>(bookSize); return totalProgress / static_cast<float>(bookSize);
} }
int Epub::resolveHrefToSpineIndex(const std::string& href) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) return -1;
// Extract filename (remove #anchor)
std::string target = href;
size_t hashPos = target.find('#');
if (hashPos != std::string::npos) target = target.substr(0, hashPos);
// Same-file reference (anchor-only)
if (target.empty()) return -1;
// Extract just the filename for comparison
size_t targetSlash = target.find_last_of('/');
std::string targetFilename = (targetSlash != std::string::npos) ? target.substr(targetSlash + 1) : target;
for (int i = 0; i < getSpineItemsCount(); i++) {
const auto& spineHref = getSpineItem(i).href;
// Try exact match first
if (spineHref == target) return i;
// Then filename-only match
size_t spineSlash = spineHref.find_last_of('/');
std::string spineFilename = (spineSlash != std::string::npos) ? spineHref.substr(spineSlash + 1) : spineHref;
if (spineFilename == targetFilename) return i;
}
return -1;
}

View File

@@ -72,4 +72,5 @@ class Epub {
size_t getBookSize() const; size_t getBookSize() const;
float calculateProgress(int currentSpineIndex, float currentSpineRead) const; float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
CssParser* getCssParser() const { return cssParser.get(); } CssParser* getCssParser() const { return cssParser.get(); }
int resolveHrefToSpineIndex(const std::string& href) const;
}; };

View File

@@ -0,0 +1,13 @@
#pragma once
#include <cstring>
struct FootnoteEntry {
char number[24];
char href[64];
FootnoteEntry() {
number[0] = '\0';
href[0] = '\0';
}
};

View File

@@ -67,6 +67,18 @@ bool Page::serialize(FsFile& file) const {
} }
} }
// Serialize footnotes (clamp to MAX_FOOTNOTES_PER_PAGE to match addFootnote/deserialize limits)
const uint16_t fnCount = std::min<uint16_t>(footnotes.size(), MAX_FOOTNOTES_PER_PAGE);
serialization::writePod(file, fnCount);
for (uint16_t i = 0; i < fnCount; i++) {
const auto& fn = footnotes[i];
if (file.write(fn.number, sizeof(fn.number)) != sizeof(fn.number) ||
file.write(fn.href, sizeof(fn.href)) != sizeof(fn.href)) {
LOG_ERR("PGE", "Failed to write footnote");
return false;
}
}
return true; return true;
} }
@@ -92,5 +104,24 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
} }
} }
// Deserialize footnotes
uint16_t fnCount;
serialization::readPod(file, fnCount);
if (fnCount > MAX_FOOTNOTES_PER_PAGE) {
LOG_ERR("PGE", "Invalid footnote count %u", fnCount);
return nullptr;
}
page->footnotes.resize(fnCount);
for (uint16_t i = 0; i < fnCount; i++) {
auto& entry = page->footnotes[i];
if (file.read(entry.number, sizeof(entry.number)) != sizeof(entry.number) ||
file.read(entry.href, sizeof(entry.href)) != sizeof(entry.href)) {
LOG_ERR("PGE", "Failed to read footnote %u", i);
return nullptr;
}
entry.number[sizeof(entry.number) - 1] = '\0';
entry.href[sizeof(entry.href) - 1] = '\0';
}
return page; return page;
} }

View File

@@ -5,6 +5,7 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "FootnoteEntry.h"
#include "blocks/ImageBlock.h" #include "blocks/ImageBlock.h"
#include "blocks/TextBlock.h" #include "blocks/TextBlock.h"
@@ -57,6 +58,19 @@ class Page {
public: public:
// the list of block index and line numbers on this page // the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements; std::vector<std::shared_ptr<PageElement>> elements;
std::vector<FootnoteEntry> footnotes;
static constexpr uint16_t MAX_FOOTNOTES_PER_PAGE = 16;
void addFootnote(const char* number, const char* href) {
if (footnotes.size() >= MAX_FOOTNOTES_PER_PAGE) return; // Cap per-page footnotes
FootnoteEntry entry;
strncpy(entry.number, number, sizeof(entry.number) - 1);
entry.number[sizeof(entry.number) - 1] = '\0';
strncpy(entry.href, href, sizeof(entry.href) - 1);
entry.href[sizeof(entry.href) - 1] = '\0';
footnotes.push_back(entry);
}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(FsFile& file) const; bool serialize(FsFile& file) const;
static std::unique_ptr<Page> deserialize(FsFile& file); static std::unique_ptr<Page> deserialize(FsFile& file);

View File

@@ -29,6 +29,7 @@ class TextBlock final : public Block {
const BlockStyle& getBlockStyle() const { return blockStyle; } const BlockStyle& getBlockStyle() const { return blockStyle; }
const std::vector<std::string>& getWords() const { return words; } const std::vector<std::string>& getWords() const { return words; }
bool isEmpty() override { return words.empty(); } bool isEmpty() override { return words.empty(); }
size_t wordCount() const { return words.size(); }
// given a renderer works out where to break the words into lines // given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const; void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; } BlockType getType() override { return TEXT_BLOCK; }

View File

@@ -49,6 +49,24 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
return false; return false;
} }
const char* getAttribute(const XML_Char** atts, const char* attrName) {
if (!atts) return nullptr;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], attrName) == 0) return atts[i + 1];
}
return nullptr;
}
bool isInternalEpubLink(const char* href) {
if (!href || href[0] == '\0') return false;
if (strncmp(href, "http://", 7) == 0 || strncmp(href, "https://", 8) == 0) return false;
if (strncmp(href, "mailto:", 7) == 0) return false;
if (strncmp(href, "ftp://", 6) == 0) return false;
if (strncmp(href, "tel:", 4) == 0) return false;
if (strncmp(href, "javascript:", 11) == 0) return false;
return true;
}
bool isHeaderOrBlock(const char* name) { bool isHeaderOrBlock(const char* name) {
return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS); return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
} }
@@ -121,6 +139,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
makePages(); makePages();
} }
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle)); currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
wordsExtractedInBlock = 0;
} }
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
@@ -430,6 +449,50 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} }
} }
// Detect internal <a href="..."> links (footnotes, cross-references)
// Note: <aside epub:type="footnote"> elements are rendered as normal content
// without special handling. Links pointing to them are collected as footnotes.
if (strcmp(name, "a") == 0) {
const char* href = getAttribute(atts, "href");
bool isInternalLink = isInternalEpubLink(href);
// Special case: javascript:void(0) links with data attributes
// Example: <a href="javascript:void(0)"
// data-xyz="{&quot;name&quot;:&quot;OPS/ch2.xhtml&quot;,&quot;frag&quot;:&quot;id46&quot;}">
if (href && strncmp(href, "javascript:", 11) == 0) {
isInternalLink = false;
// TODO: Parse data-* attributes to extract actual href
}
if (isInternalLink) {
// Flush buffer before style change
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
self->insideFootnoteLink = true;
self->footnoteLinkDepth = self->depth;
strncpy(self->currentFootnoteLinkHref, href, sizeof(self->currentFootnoteLinkHref) - 1);
self->currentFootnoteLinkHref[sizeof(self->currentFootnoteLinkHref) - 1] = '\0';
self->currentFootnoteLinkText[0] = '\0';
self->currentFootnoteLinkTextLen = 0;
// Apply underline style to visually indicate the link
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
StyleStackEntry entry;
entry.depth = self->depth;
entry.hasUnderline = true;
entry.underline = true;
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
// Skip CSS resolution — we already handled styling for this <a> tag
self->depth += 1;
return;
}
}
// Compute CSS style for this element // Compute CSS style for this element
CssStyle cssStyle; CssStyle cssStyle;
if (self->cssParser) { if (self->cssParser) {
@@ -582,6 +645,19 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
return; return;
} }
// Collect footnote link display text (for the number label)
// Skip whitespace and brackets to normalize noterefs like "[1]" → "1"
if (self->insideFootnoteLink) {
for (int i = 0; i < len; i++) {
unsigned char c = static_cast<unsigned char>(s[i]);
if (isWhitespace(c) || c == '[' || c == ']') continue;
if (self->currentFootnoteLinkTextLen < static_cast<int>(sizeof(self->currentFootnoteLinkText)) - 1) {
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = c;
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0';
}
}
}
for (int i = 0; i < len; i++) { for (int i = 0; i < len; i++) {
if (isWhitespace(s[i])) { if (isWhitespace(s[i])) {
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
@@ -743,6 +819,21 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->depth -= 1; self->depth -= 1;
// Closing a footnote link — create entry from collected text and href
if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) {
if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') {
FootnoteEntry entry;
strncpy(entry.number, self->currentFootnoteLinkText, sizeof(entry.number) - 1);
entry.number[sizeof(entry.number) - 1] = '\0';
strncpy(entry.href, self->currentFootnoteLinkHref, sizeof(entry.href) - 1);
entry.href[sizeof(entry.href) - 1] = '\0';
int wordIndex =
self->wordsExtractedInBlock + (self->currentTextBlock ? static_cast<int>(self->currentTextBlock->size()) : 0);
self->pendingFootnotes.push_back({wordIndex, entry});
}
self->insideFootnoteLink = false;
}
// Leaving skip // Leaving skip
if (self->skipUntilDepth == self->depth) { if (self->skipUntilDepth == self->depth) {
self->skipUntilDepth = INT_MAX; self->skipUntilDepth = INT_MAX;
@@ -910,6 +1001,15 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
currentPageNextY = 0; currentPageNextY = 0;
} }
// Track cumulative words to assign footnotes to the page containing their anchor
wordsExtractedInBlock += line->wordCount();
auto footnoteIt = pendingFootnotes.begin();
while (footnoteIt != pendingFootnotes.end() && footnoteIt->first <= wordsExtractedInBlock) {
currentPage->addFootnote(footnoteIt->second.number, footnoteIt->second.href);
++footnoteIt;
}
pendingFootnotes.erase(pendingFootnotes.begin(), footnoteIt);
// Apply horizontal left inset (margin + padding) as x position offset // Apply horizontal left inset (margin + padding) as x position offset
const int16_t xOffset = line->getBlockStyle().leftInset(); const int16_t xOffset = line->getBlockStyle().leftInset();
currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY)); currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY));
@@ -947,6 +1047,16 @@ void ChapterHtmlSlimParser::makePages() {
renderer, fontId, effectiveWidth, renderer, fontId, effectiveWidth,
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); }); [this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
// Fallback: transfer any remaining pending footnotes to current page.
// Normally addLineToPage handles this via word-index tracking, but this catches
// edge cases where a footnote's word index equals the exact block size.
if (!pendingFootnotes.empty() && currentPage) {
for (const auto& [idx, fn] : pendingFootnotes) {
currentPage->addFootnote(fn.number, fn.href);
}
pendingFootnotes.clear();
}
// Apply bottom spacing after the paragraph (stored in pixels) // Apply bottom spacing after the paragraph (stored in pixels)
if (blockStyle.marginBottom > 0) { if (blockStyle.marginBottom > 0) {
currentPageNextY += blockStyle.marginBottom; currentPageNextY += blockStyle.marginBottom;

View File

@@ -5,7 +5,9 @@
#include <climits> #include <climits>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <vector>
#include "../FootnoteEntry.h"
#include "../ParsedText.h" #include "../ParsedText.h"
#include "../blocks/ImageBlock.h" #include "../blocks/ImageBlock.h"
#include "../blocks/TextBlock.h" #include "../blocks/TextBlock.h"
@@ -66,6 +68,15 @@ class ChapterHtmlSlimParser {
int tableRowIndex = 0; int tableRowIndex = 0;
int tableColIndex = 0; int tableColIndex = 0;
// Footnote link tracking
bool insideFootnoteLink = false;
int footnoteLinkDepth = -1;
char currentFootnoteLinkText[24] = {};
int currentFootnoteLinkTextLen = 0;
char currentFootnoteLinkHref[64] = {};
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
int wordsExtractedInBlock = 0;
void updateEffectiveInlineStyle(); void updateEffectiveInlineStyle();
void startNewTextBlock(const BlockStyle& blockStyle); void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer(); void flushPartWordBuffer();

View File

@@ -331,4 +331,7 @@ STR_UPLOAD: "Upload"
STR_BOOK_S_STYLE: "Book's Style" STR_BOOK_S_STYLE: "Book's Style"
STR_EMBEDDED_STYLE: "Embedded Style" STR_EMBEDDED_STYLE: "Embedded Style"
STR_OPDS_SERVER_URL: "OPDS Server URL" STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_SCREENSHOT_BUTTON: "Take screenshot" STR_FOOTNOTES: "Footnotes"
STR_NO_FOOTNOTES: "No footnotes on this page"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Take screenshot"

View File

@@ -11,6 +11,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderFootnotesActivity.h"
#include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderPercentSelectionActivity.h"
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h" #include "KOReaderSyncActivity.h"
@@ -177,7 +178,8 @@ void EpubReaderActivity::loop() {
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderMenuActivity( enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, 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); })); [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
} }
@@ -187,8 +189,12 @@ void EpubReaderActivity::loop() {
return; 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 (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
if (footnoteDepth > 0) {
restoreSavedPosition();
return;
}
onGoHome(); onGoHome();
return; return;
} }
@@ -379,6 +385,23 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
break; 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: { case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: {
// Launch the slider-based percent selector and return here on confirm/cancel. // Launch the slider-based percent selector and return here on confirm/cancel.
float bookProgress = 0.0f; 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 // TODO: prevent infinite loop if the page keeps failing to load for some reason
return; return;
} }
// Collect footnotes from the loaded page
currentPageFootnotes = std::move(p->footnotes);
const auto start = millis(); const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start); LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
@@ -757,3 +784,57 @@ void EpubReaderActivity::renderStatusBar() const {
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title); 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 #pragma once
#include <Epub.h> #include <Epub.h>
#include <Epub/FootnoteEntry.h>
#include <Epub/Section.h> #include <Epub/Section.h>
#include "EpubReaderMenuActivity.h" #include "EpubReaderMenuActivity.h"
@@ -25,6 +26,16 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void()> onGoHome; 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, void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft); int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const; void renderStatusBar() const;
@@ -35,6 +46,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
void applyOrientation(uint8_t orientation); void applyOrientation(uint8_t orientation);
// Footnote navigation
void navigateToHref(const char* href, bool savePosition = false);
void restoreSavedPosition();
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome) 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 "components/UITheme.h"
#include "fontIds.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() { void EpubReaderMenuActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
requestUpdate(); requestUpdate();

View File

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