## 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>
128 lines
3.8 KiB
C++
128 lines
3.8 KiB
C++
#include "Page.h"
|
|
|
|
#include <Logging.h>
|
|
#include <Serialization.h>
|
|
|
|
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
|
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
|
|
}
|
|
|
|
bool PageLine::serialize(FsFile& file) {
|
|
serialization::writePod(file, xPos);
|
|
serialization::writePod(file, yPos);
|
|
|
|
// serialize TextBlock pointed to by PageLine
|
|
return block->serialize(file);
|
|
}
|
|
|
|
std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
|
|
int16_t xPos;
|
|
int16_t yPos;
|
|
serialization::readPod(file, xPos);
|
|
serialization::readPod(file, yPos);
|
|
|
|
auto tb = TextBlock::deserialize(file);
|
|
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
|
}
|
|
|
|
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
|
// Images don't use fontId or text rendering
|
|
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
|
|
}
|
|
|
|
bool PageImage::serialize(FsFile& file) {
|
|
serialization::writePod(file, xPos);
|
|
serialization::writePod(file, yPos);
|
|
|
|
// serialize ImageBlock
|
|
return imageBlock->serialize(file);
|
|
}
|
|
|
|
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
|
int16_t xPos;
|
|
int16_t yPos;
|
|
serialization::readPod(file, xPos);
|
|
serialization::readPod(file, yPos);
|
|
|
|
auto ib = ImageBlock::deserialize(file);
|
|
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
|
}
|
|
|
|
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
|
for (auto& element : elements) {
|
|
element->render(renderer, fontId, xOffset, yOffset);
|
|
}
|
|
}
|
|
|
|
bool Page::serialize(FsFile& file) const {
|
|
const uint16_t count = elements.size();
|
|
serialization::writePod(file, count);
|
|
|
|
for (const auto& el : elements) {
|
|
// Use getTag() method to determine type
|
|
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
|
|
|
|
if (!el->serialize(file)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
|
auto page = std::unique_ptr<Page>(new Page());
|
|
|
|
uint16_t count;
|
|
serialization::readPod(file, count);
|
|
|
|
for (uint16_t i = 0; i < count; i++) {
|
|
uint8_t tag;
|
|
serialization::readPod(file, tag);
|
|
|
|
if (tag == TAG_PageLine) {
|
|
auto pl = PageLine::deserialize(file);
|
|
page->elements.push_back(std::move(pl));
|
|
} else if (tag == TAG_PageImage) {
|
|
auto pi = PageImage::deserialize(file);
|
|
page->elements.push_back(std::move(pi));
|
|
} else {
|
|
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
|
return nullptr;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|