A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Uri Tauber
Arthur Tazhitdinov
and committed by
GitHub
30d8a8d0 451774dd

+481 -22
+27
lib/Epub/Epub.cpp
··· 858 858 const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize; 859 859 return totalProgress / static_cast<float>(bookSize); 860 860 } 861 + 862 + int Epub::resolveHrefToSpineIndex(const std::string& href) const { 863 + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) return -1; 864 + 865 + // Extract filename (remove #anchor) 866 + std::string target = href; 867 + size_t hashPos = target.find('#'); 868 + if (hashPos != std::string::npos) target = target.substr(0, hashPos); 869 + 870 + // Same-file reference (anchor-only) 871 + if (target.empty()) return -1; 872 + 873 + // Extract just the filename for comparison 874 + size_t targetSlash = target.find_last_of('/'); 875 + std::string targetFilename = (targetSlash != std::string::npos) ? target.substr(targetSlash + 1) : target; 876 + 877 + for (int i = 0; i < getSpineItemsCount(); i++) { 878 + const auto& spineHref = getSpineItem(i).href; 879 + // Try exact match first 880 + if (spineHref == target) return i; 881 + // Then filename-only match 882 + size_t spineSlash = spineHref.find_last_of('/'); 883 + std::string spineFilename = (spineSlash != std::string::npos) ? spineHref.substr(spineSlash + 1) : spineHref; 884 + if (spineFilename == targetFilename) return i; 885 + } 886 + return -1; 887 + }
+1
lib/Epub/Epub.h
··· 72 72 size_t getBookSize() const; 73 73 float calculateProgress(int currentSpineIndex, float currentSpineRead) const; 74 74 CssParser* getCssParser() const { return cssParser.get(); } 75 + int resolveHrefToSpineIndex(const std::string& href) const; 75 76 };
+13
lib/Epub/Epub/FootnoteEntry.h
··· 1 + #pragma once 2 + 3 + #include <cstring> 4 + 5 + struct FootnoteEntry { 6 + char number[24]; 7 + char href[64]; 8 + 9 + FootnoteEntry() { 10 + number[0] = '\0'; 11 + href[0] = '\0'; 12 + } 13 + };
+31
lib/Epub/Epub/Page.cpp
··· 67 67 } 68 68 } 69 69 70 + // Serialize footnotes (clamp to MAX_FOOTNOTES_PER_PAGE to match addFootnote/deserialize limits) 71 + const uint16_t fnCount = std::min<uint16_t>(footnotes.size(), MAX_FOOTNOTES_PER_PAGE); 72 + serialization::writePod(file, fnCount); 73 + for (uint16_t i = 0; i < fnCount; i++) { 74 + const auto& fn = footnotes[i]; 75 + if (file.write(fn.number, sizeof(fn.number)) != sizeof(fn.number) || 76 + file.write(fn.href, sizeof(fn.href)) != sizeof(fn.href)) { 77 + LOG_ERR("PGE", "Failed to write footnote"); 78 + return false; 79 + } 80 + } 81 + 70 82 return true; 71 83 } 72 84 ··· 90 102 LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag); 91 103 return nullptr; 92 104 } 105 + } 106 + 107 + // Deserialize footnotes 108 + uint16_t fnCount; 109 + serialization::readPod(file, fnCount); 110 + if (fnCount > MAX_FOOTNOTES_PER_PAGE) { 111 + LOG_ERR("PGE", "Invalid footnote count %u", fnCount); 112 + return nullptr; 113 + } 114 + page->footnotes.resize(fnCount); 115 + for (uint16_t i = 0; i < fnCount; i++) { 116 + auto& entry = page->footnotes[i]; 117 + if (file.read(entry.number, sizeof(entry.number)) != sizeof(entry.number) || 118 + file.read(entry.href, sizeof(entry.href)) != sizeof(entry.href)) { 119 + LOG_ERR("PGE", "Failed to read footnote %u", i); 120 + return nullptr; 121 + } 122 + entry.number[sizeof(entry.number) - 1] = '\0'; 123 + entry.href[sizeof(entry.href) - 1] = '\0'; 93 124 } 94 125 95 126 return page;
+14
lib/Epub/Epub/Page.h
··· 5 5 #include <utility> 6 6 #include <vector> 7 7 8 + #include "FootnoteEntry.h" 8 9 #include "blocks/ImageBlock.h" 9 10 #include "blocks/TextBlock.h" 10 11 ··· 57 58 public: 58 59 // the list of block index and line numbers on this page 59 60 std::vector<std::shared_ptr<PageElement>> elements; 61 + std::vector<FootnoteEntry> footnotes; 62 + static constexpr uint16_t MAX_FOOTNOTES_PER_PAGE = 16; 63 + 64 + void addFootnote(const char* number, const char* href) { 65 + if (footnotes.size() >= MAX_FOOTNOTES_PER_PAGE) return; // Cap per-page footnotes 66 + FootnoteEntry entry; 67 + strncpy(entry.number, number, sizeof(entry.number) - 1); 68 + entry.number[sizeof(entry.number) - 1] = '\0'; 69 + strncpy(entry.href, href, sizeof(entry.href) - 1); 70 + entry.href[sizeof(entry.href) - 1] = '\0'; 71 + footnotes.push_back(entry); 72 + } 73 + 60 74 void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; 61 75 bool serialize(FsFile& file) const; 62 76 static std::unique_ptr<Page> deserialize(FsFile& file);
+1
lib/Epub/Epub/blocks/TextBlock.h
··· 29 29 const BlockStyle& getBlockStyle() const { return blockStyle; } 30 30 const std::vector<std::string>& getWords() const { return words; } 31 31 bool isEmpty() override { return words.empty(); } 32 + size_t wordCount() const { return words.size(); } 32 33 // given a renderer works out where to break the words into lines 33 34 void render(const GfxRenderer& renderer, int fontId, int x, int y) const; 34 35 BlockType getType() override { return TEXT_BLOCK; }
+110
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
··· 49 49 return false; 50 50 } 51 51 52 + const char* getAttribute(const XML_Char** atts, const char* attrName) { 53 + if (!atts) return nullptr; 54 + for (int i = 0; atts[i]; i += 2) { 55 + if (strcmp(atts[i], attrName) == 0) return atts[i + 1]; 56 + } 57 + return nullptr; 58 + } 59 + 60 + bool isInternalEpubLink(const char* href) { 61 + if (!href || href[0] == '\0') return false; 62 + if (strncmp(href, "http://", 7) == 0 || strncmp(href, "https://", 8) == 0) return false; 63 + if (strncmp(href, "mailto:", 7) == 0) return false; 64 + if (strncmp(href, "ftp://", 6) == 0) return false; 65 + if (strncmp(href, "tel:", 4) == 0) return false; 66 + if (strncmp(href, "javascript:", 11) == 0) return false; 67 + return true; 68 + } 69 + 52 70 bool isHeaderOrBlock(const char* name) { 53 71 return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS); 54 72 } ··· 121 139 makePages(); 122 140 } 123 141 currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle)); 142 + wordsExtractedInBlock = 0; 124 143 } 125 144 126 145 void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { ··· 430 449 } 431 450 } 432 451 452 + // Detect internal <a href="..."> links (footnotes, cross-references) 453 + // Note: <aside epub:type="footnote"> elements are rendered as normal content 454 + // without special handling. Links pointing to them are collected as footnotes. 455 + if (strcmp(name, "a") == 0) { 456 + const char* href = getAttribute(atts, "href"); 457 + 458 + bool isInternalLink = isInternalEpubLink(href); 459 + 460 + // Special case: javascript:void(0) links with data attributes 461 + // Example: <a href="javascript:void(0)" 462 + // data-xyz="{&quot;name&quot;:&quot;OPS/ch2.xhtml&quot;,&quot;frag&quot;:&quot;id46&quot;}"> 463 + if (href && strncmp(href, "javascript:", 11) == 0) { 464 + isInternalLink = false; 465 + // TODO: Parse data-* attributes to extract actual href 466 + } 467 + 468 + if (isInternalLink) { 469 + // Flush buffer before style change 470 + if (self->partWordBufferIndex > 0) { 471 + self->flushPartWordBuffer(); 472 + self->nextWordContinues = true; 473 + } 474 + self->insideFootnoteLink = true; 475 + self->footnoteLinkDepth = self->depth; 476 + strncpy(self->currentFootnoteLinkHref, href, sizeof(self->currentFootnoteLinkHref) - 1); 477 + self->currentFootnoteLinkHref[sizeof(self->currentFootnoteLinkHref) - 1] = '\0'; 478 + self->currentFootnoteLinkText[0] = '\0'; 479 + self->currentFootnoteLinkTextLen = 0; 480 + 481 + // Apply underline style to visually indicate the link 482 + self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth); 483 + StyleStackEntry entry; 484 + entry.depth = self->depth; 485 + entry.hasUnderline = true; 486 + entry.underline = true; 487 + self->inlineStyleStack.push_back(entry); 488 + self->updateEffectiveInlineStyle(); 489 + 490 + // Skip CSS resolution — we already handled styling for this <a> tag 491 + self->depth += 1; 492 + return; 493 + } 494 + } 495 + 433 496 // Compute CSS style for this element 434 497 CssStyle cssStyle; 435 498 if (self->cssParser) { ··· 582 645 return; 583 646 } 584 647 648 + // Collect footnote link display text (for the number label) 649 + // Skip whitespace and brackets to normalize noterefs like "[1]" → "1" 650 + if (self->insideFootnoteLink) { 651 + for (int i = 0; i < len; i++) { 652 + unsigned char c = static_cast<unsigned char>(s[i]); 653 + if (isWhitespace(c) || c == '[' || c == ']') continue; 654 + if (self->currentFootnoteLinkTextLen < static_cast<int>(sizeof(self->currentFootnoteLinkText)) - 1) { 655 + self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = c; 656 + self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0'; 657 + } 658 + } 659 + } 660 + 585 661 for (int i = 0; i < len; i++) { 586 662 if (isWhitespace(s[i])) { 587 663 // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it ··· 743 819 744 820 self->depth -= 1; 745 821 822 + // Closing a footnote link — create entry from collected text and href 823 + if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) { 824 + if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') { 825 + FootnoteEntry entry; 826 + strncpy(entry.number, self->currentFootnoteLinkText, sizeof(entry.number) - 1); 827 + entry.number[sizeof(entry.number) - 1] = '\0'; 828 + strncpy(entry.href, self->currentFootnoteLinkHref, sizeof(entry.href) - 1); 829 + entry.href[sizeof(entry.href) - 1] = '\0'; 830 + int wordIndex = 831 + self->wordsExtractedInBlock + (self->currentTextBlock ? static_cast<int>(self->currentTextBlock->size()) : 0); 832 + self->pendingFootnotes.push_back({wordIndex, entry}); 833 + } 834 + self->insideFootnoteLink = false; 835 + } 836 + 746 837 // Leaving skip 747 838 if (self->skipUntilDepth == self->depth) { 748 839 self->skipUntilDepth = INT_MAX; ··· 910 1001 currentPageNextY = 0; 911 1002 } 912 1003 1004 + // Track cumulative words to assign footnotes to the page containing their anchor 1005 + wordsExtractedInBlock += line->wordCount(); 1006 + auto footnoteIt = pendingFootnotes.begin(); 1007 + while (footnoteIt != pendingFootnotes.end() && footnoteIt->first <= wordsExtractedInBlock) { 1008 + currentPage->addFootnote(footnoteIt->second.number, footnoteIt->second.href); 1009 + ++footnoteIt; 1010 + } 1011 + pendingFootnotes.erase(pendingFootnotes.begin(), footnoteIt); 1012 + 913 1013 // Apply horizontal left inset (margin + padding) as x position offset 914 1014 const int16_t xOffset = line->getBlockStyle().leftInset(); 915 1015 currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY)); ··· 946 1046 currentTextBlock->layoutAndExtractLines( 947 1047 renderer, fontId, effectiveWidth, 948 1048 [this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); }); 1049 + 1050 + // Fallback: transfer any remaining pending footnotes to current page. 1051 + // Normally addLineToPage handles this via word-index tracking, but this catches 1052 + // edge cases where a footnote's word index equals the exact block size. 1053 + if (!pendingFootnotes.empty() && currentPage) { 1054 + for (const auto& [idx, fn] : pendingFootnotes) { 1055 + currentPage->addFootnote(fn.number, fn.href); 1056 + } 1057 + pendingFootnotes.clear(); 1058 + } 949 1059 950 1060 // Apply bottom spacing after the paragraph (stored in pixels) 951 1061 if (blockStyle.marginBottom > 0) {
+11
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
··· 5 5 #include <climits> 6 6 #include <functional> 7 7 #include <memory> 8 + #include <vector> 8 9 10 + #include "../FootnoteEntry.h" 9 11 #include "../ParsedText.h" 10 12 #include "../blocks/ImageBlock.h" 11 13 #include "../blocks/TextBlock.h" ··· 65 67 int tableDepth = 0; 66 68 int tableRowIndex = 0; 67 69 int tableColIndex = 0; 70 + 71 + // Footnote link tracking 72 + bool insideFootnoteLink = false; 73 + int footnoteLinkDepth = -1; 74 + char currentFootnoteLinkText[24] = {}; 75 + int currentFootnoteLinkTextLen = 0; 76 + char currentFootnoteLinkHref[64] = {}; 77 + std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry> 78 + int wordsExtractedInBlock = 0; 68 79 69 80 void updateEffectiveInlineStyle(); 70 81 void startNewTextBlock(const BlockStyle& blockStyle);
+4 -1
lib/I18n/translations/english.yaml
··· 331 331 STR_BOOK_S_STYLE: "Book's Style" 332 332 STR_EMBEDDED_STYLE: "Embedded Style" 333 333 STR_OPDS_SERVER_URL: "OPDS Server URL" 334 - STR_SCREENSHOT_BUTTON: "Take screenshot" 334 + STR_FOOTNOTES: "Footnotes" 335 + STR_NO_FOOTNOTES: "No footnotes on this page" 336 + STR_LINK: "[link]" 337 + STR_SCREENSHOT_BUTTON: "Take screenshot"
+83 -2
src/activities/reader/EpubReaderActivity.cpp
··· 11 11 #include "CrossPointSettings.h" 12 12 #include "CrossPointState.h" 13 13 #include "EpubReaderChapterSelectionActivity.h" 14 + #include "EpubReaderFootnotesActivity.h" 14 15 #include "EpubReaderPercentSelectionActivity.h" 15 16 #include "KOReaderCredentialStore.h" 16 17 #include "KOReaderSyncActivity.h" ··· 177 178 exitActivity(); 178 179 enterNewActivity(new EpubReaderMenuActivity( 179 180 this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, 180 - SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); }, 181 + SETTINGS.orientation, !currentPageFootnotes.empty(), 182 + [this](const uint8_t orientation) { onReaderMenuBack(orientation); }, 181 183 [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); 182 184 } 183 185 ··· 187 189 return; 188 190 } 189 191 190 - // Short press BACK goes directly to home 192 + // Short press BACK goes directly to home (or restores position if viewing footnote) 191 193 if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { 194 + if (footnoteDepth > 0) { 195 + restoreSavedPosition(); 196 + return; 197 + } 192 198 onGoHome(); 193 199 return; 194 200 } ··· 379 385 380 386 break; 381 387 } 388 + case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { 389 + exitActivity(); 390 + enterNewActivity(new EpubReaderFootnotesActivity( 391 + this->renderer, this->mappedInput, currentPageFootnotes, 392 + [this] { 393 + // Go back from footnotes list 394 + exitActivity(); 395 + requestUpdate(); 396 + }, 397 + [this](const char* href) { 398 + // Navigate to selected footnote 399 + navigateToHref(href, true); 400 + exitActivity(); 401 + requestUpdate(); 402 + })); 403 + break; 404 + } 382 405 case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: { 383 406 // Launch the slider-based percent selector and return here on confirm/cancel. 384 407 float bookProgress = 0.0f; ··· 641 664 // TODO: prevent infinite loop if the page keeps failing to load for some reason 642 665 return; 643 666 } 667 + 668 + // Collect footnotes from the loaded page 669 + currentPageFootnotes = std::move(p->footnotes); 670 + 644 671 const auto start = millis(); 645 672 renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); 646 673 LOG_DBG("ERS", "Rendered page in %dms", millis() - start); ··· 757 784 758 785 GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title); 759 786 } 787 + 788 + void EpubReaderActivity::navigateToHref(const char* href, const bool savePosition) { 789 + if (!epub || !href) return; 790 + 791 + // Push current position onto saved stack 792 + if (savePosition && section && footnoteDepth < MAX_FOOTNOTE_DEPTH) { 793 + savedPositions[footnoteDepth] = {currentSpineIndex, section->currentPage}; 794 + footnoteDepth++; 795 + LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex, section->currentPage); 796 + } 797 + 798 + std::string hrefStr(href); 799 + 800 + // Check for same-file anchor reference (#anchor only) 801 + bool sameFile = !hrefStr.empty() && hrefStr[0] == '#'; 802 + 803 + int targetSpineIndex; 804 + if (sameFile) { 805 + // Same file — navigate to page 0 of current spine item 806 + targetSpineIndex = currentSpineIndex; 807 + } else { 808 + targetSpineIndex = epub->resolveHrefToSpineIndex(hrefStr); 809 + } 810 + 811 + if (targetSpineIndex < 0) { 812 + LOG_DBG("ERS", "Could not resolve href: %s", href); 813 + if (savePosition && footnoteDepth > 0) footnoteDepth--; // undo push 814 + return; 815 + } 816 + 817 + { 818 + RenderLock lock(*this); 819 + currentSpineIndex = targetSpineIndex; 820 + nextPageNumber = 0; 821 + section.reset(); 822 + } 823 + requestUpdate(); 824 + LOG_DBG("ERS", "Navigated to spine %d for href: %s", targetSpineIndex, href); 825 + } 826 + 827 + void EpubReaderActivity::restoreSavedPosition() { 828 + if (footnoteDepth <= 0) return; 829 + footnoteDepth--; 830 + const auto& pos = savedPositions[footnoteDepth]; 831 + LOG_DBG("ERS", "Restoring position [%d]: spine %d, page %d", footnoteDepth, pos.spineIndex, pos.pageNumber); 832 + 833 + { 834 + RenderLock lock(*this); 835 + currentSpineIndex = pos.spineIndex; 836 + nextPageNumber = pos.pageNumber; 837 + section.reset(); 838 + } 839 + requestUpdate(); 840 + }
+15
src/activities/reader/EpubReaderActivity.h
··· 1 1 #pragma once 2 2 #include <Epub.h> 3 + #include <Epub/FootnoteEntry.h> 3 4 #include <Epub/Section.h> 4 5 5 6 #include "EpubReaderMenuActivity.h" ··· 25 26 const std::function<void()> onGoBack; 26 27 const std::function<void()> onGoHome; 27 28 29 + // Footnote support 30 + std::vector<FootnoteEntry> currentPageFootnotes; 31 + struct SavedPosition { 32 + int spineIndex; 33 + int pageNumber; 34 + }; 35 + static constexpr int MAX_FOOTNOTE_DEPTH = 3; 36 + SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {}; 37 + int footnoteDepth = 0; 38 + 28 39 void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight, 29 40 int orientedMarginBottom, int orientedMarginLeft); 30 41 void renderStatusBar() const; ··· 34 45 void onReaderMenuBack(uint8_t orientation); 35 46 void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); 36 47 void applyOrientation(uint8_t orientation); 48 + 49 + // Footnote navigation 50 + void navigateToHref(const char* href, bool savePosition = false); 51 + void restoreSavedPosition(); 37 52 38 53 public: 39 54 explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
+95
src/activities/reader/EpubReaderFootnotesActivity.cpp
··· 1 + #include "EpubReaderFootnotesActivity.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <I18n.h> 5 + 6 + #include <algorithm> 7 + 8 + #include "MappedInputManager.h" 9 + #include "components/UITheme.h" 10 + #include "fontIds.h" 11 + 12 + void EpubReaderFootnotesActivity::onEnter() { 13 + ActivityWithSubactivity::onEnter(); 14 + selectedIndex = 0; 15 + requestUpdate(); 16 + } 17 + 18 + void EpubReaderFootnotesActivity::onExit() { ActivityWithSubactivity::onExit(); } 19 + 20 + void EpubReaderFootnotesActivity::loop() { 21 + if (subActivity) { 22 + subActivity->loop(); 23 + return; 24 + } 25 + 26 + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { 27 + onGoBack(); 28 + return; 29 + } 30 + 31 + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 32 + if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) { 33 + onSelectFootnote(footnotes[selectedIndex].href); 34 + } 35 + return; 36 + } 37 + 38 + buttonNavigator.onNext([this] { 39 + if (!footnotes.empty()) { 40 + selectedIndex = (selectedIndex + 1) % footnotes.size(); 41 + requestUpdate(); 42 + } 43 + }); 44 + 45 + buttonNavigator.onPrevious([this] { 46 + if (!footnotes.empty()) { 47 + selectedIndex = (selectedIndex - 1 + footnotes.size()) % footnotes.size(); 48 + requestUpdate(); 49 + } 50 + }); 51 + } 52 + 53 + void EpubReaderFootnotesActivity::render(Activity::RenderLock&&) { 54 + renderer.clearScreen(); 55 + 56 + renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_FOOTNOTES), true, EpdFontFamily::BOLD); 57 + 58 + if (footnotes.empty()) { 59 + renderer.drawCenteredText(UI_10_FONT_ID, 90, tr(STR_NO_FOOTNOTES)); 60 + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); 61 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 62 + renderer.displayBuffer(); 63 + return; 64 + } 65 + 66 + constexpr int startY = 50; 67 + constexpr int lineHeight = 36; 68 + const int screenWidth = renderer.getScreenWidth(); 69 + constexpr int marginLeft = 20; 70 + 71 + const int visibleCount = std::max(1, (renderer.getScreenHeight() - startY) / lineHeight); 72 + if (selectedIndex < scrollOffset) scrollOffset = selectedIndex; 73 + if (selectedIndex >= scrollOffset + visibleCount) scrollOffset = selectedIndex - visibleCount + 1; 74 + 75 + for (int i = scrollOffset; i < static_cast<int>(footnotes.size()) && i < scrollOffset + visibleCount; i++) { 76 + const int y = startY + (i - scrollOffset) * lineHeight; 77 + const bool isSelected = (i == selectedIndex); 78 + 79 + if (isSelected) { 80 + renderer.fillRect(0, y, screenWidth, lineHeight, true); 81 + } 82 + 83 + // Show footnote number and abbreviated href 84 + std::string label = footnotes[i].number; 85 + if (label.empty()) { 86 + label = tr(STR_LINK); 87 + } 88 + renderer.drawText(UI_10_FONT_ID, marginLeft, y + 4, label.c_str(), !isSelected); 89 + } 90 + 91 + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "", ""); 92 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 93 + 94 + renderer.displayBuffer(); 95 + }
+35
src/activities/reader/EpubReaderFootnotesActivity.h
··· 1 + #pragma once 2 + 3 + #include <Epub/FootnoteEntry.h> 4 + 5 + #include <cstring> 6 + #include <functional> 7 + #include <vector> 8 + 9 + #include "../ActivityWithSubactivity.h" 10 + #include "util/ButtonNavigator.h" 11 + 12 + class EpubReaderFootnotesActivity final : public ActivityWithSubactivity { 13 + public: 14 + explicit EpubReaderFootnotesActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 15 + const std::vector<FootnoteEntry>& footnotes, 16 + const std::function<void()>& onGoBack, 17 + const std::function<void(const char*)>& onSelectFootnote) 18 + : ActivityWithSubactivity("EpubReaderFootnotes", renderer, mappedInput), 19 + footnotes(footnotes), 20 + onGoBack(onGoBack), 21 + onSelectFootnote(onSelectFootnote) {} 22 + 23 + void onEnter() override; 24 + void onExit() override; 25 + void loop() override; 26 + void render(Activity::RenderLock&&) override; 27 + 28 + private: 29 + const std::vector<FootnoteEntry>& footnotes; 30 + const std::function<void()> onGoBack; 31 + const std::function<void(const char*)> onSelectFootnote; 32 + int selectedIndex = 0; 33 + int scrollOffset = 0; 34 + ButtonNavigator buttonNavigator; 35 + };
+32
src/activities/reader/EpubReaderMenuActivity.cpp
··· 7 7 #include "components/UITheme.h" 8 8 #include "fontIds.h" 9 9 10 + EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 11 + const std::string& title, const int currentPage, const int totalPages, 12 + const int bookProgressPercent, const uint8_t currentOrientation, 13 + const bool hasFootnotes, const std::function<void(uint8_t)>& onBack, 14 + const std::function<void(MenuAction)>& onAction) 15 + : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), 16 + menuItems(buildMenuItems(hasFootnotes)), 17 + title(title), 18 + pendingOrientation(currentOrientation), 19 + currentPage(currentPage), 20 + totalPages(totalPages), 21 + bookProgressPercent(bookProgressPercent), 22 + onBack(onBack), 23 + onAction(onAction) {} 24 + 25 + std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) { 26 + std::vector<MenuItem> items; 27 + items.reserve(9); 28 + items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); 29 + if (hasFootnotes) { 30 + items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); 31 + } 32 + items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}); 33 + items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); 34 + items.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON}); 35 + items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR}); 36 + items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}); 37 + items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}); 38 + items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); 39 + return items; 40 + } 41 + 10 42 void EpubReaderMenuActivity::onEnter() { 11 43 ActivityWithSubactivity::onEnter(); 12 44 requestUpdate();
+9 -19
src/activities/reader/EpubReaderMenuActivity.h
··· 14 14 // Menu actions available from the reader menu. 15 15 enum class MenuAction { 16 16 SELECT_CHAPTER, 17 + FOOTNOTES, 17 18 GO_TO_PERCENT, 18 19 ROTATE_SCREEN, 19 20 SCREENSHOT, ··· 25 26 26 27 explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, 27 28 const int currentPage, const int totalPages, const int bookProgressPercent, 28 - const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack, 29 - const std::function<void(MenuAction)>& onAction) 30 - : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), 31 - title(title), 32 - pendingOrientation(currentOrientation), 33 - currentPage(currentPage), 34 - totalPages(totalPages), 35 - bookProgressPercent(bookProgressPercent), 36 - onBack(onBack), 37 - onAction(onAction) {} 29 + const uint8_t currentOrientation, const bool hasFootnotes, 30 + const std::function<void(uint8_t)>& onBack, 31 + const std::function<void(MenuAction)>& onAction); 38 32 39 33 void onEnter() override; 40 34 void onExit() override; ··· 47 41 StrId labelId; 48 42 }; 49 43 50 - // Fixed menu layout (order matters for up/down navigation). 51 - const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}, 52 - {MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}, 53 - {MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}, 54 - {MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON}, 55 - {MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR}, 56 - {MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}, 57 - {MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}, 58 - {MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}}; 44 + static std::vector<MenuItem> buildMenuItems(bool hasFootnotes); 45 + 46 + // Fixed menu layout 47 + const std::vector<MenuItem> menuItems; 48 + 59 49 int selectedIndex = 0; 60 50 61 51 ButtonNavigator buttonNavigator;