Merge branch 'feature/footnotes'

This commit is contained in:
Jérôme Launay 2025-12-17 12:40:39 +01:00
commit dcf2b257f4
16 changed files with 1809 additions and 165 deletions

View File

@ -128,6 +128,10 @@ bool Epub::load() {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
ZipFile zip("/sd" + filepath); ZipFile zip("/sd" + filepath);
if (!footnotePages) {
footnotePages = new std::unordered_set<std::string>();
}
std::string contentOpfFilePath; std::string contentOpfFilePath;
if (!findContentOpfFile(&contentOpfFilePath)) { if (!findContentOpfFile(&contentOpfFilePath)) {
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
@ -253,15 +257,32 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
return zip.getInflatedFileSize(path.c_str(), size); return zip.getInflatedFileSize(path.c_str(), size);
} }
int Epub::getSpineItemsCount() const { return spine.size(); } int Epub::getSpineItemsCount() const {
int virtualCount = virtualSpineItems ? virtualSpineItems->size() : 0;
return spine.size() + virtualCount;
}
std::string& Epub::getSpineItem(const int spineIndex) { std::string Epub::getSpineItem(const int spineIndex) const {
if (spineIndex < 0 || spineIndex >= spine.size()) { if (spineIndex < 0) {
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); Serial.printf("[%lu] [EBP] getSpineItem index:%d is negative\n", millis(), spineIndex);
return spine.at(0).second; return "";
} }
return spine.at(spineIndex).second; // Normal spine item
if (spineIndex < static_cast<int>(spine.size())) {
return contentBasePath + spine.at(spineIndex).second;
}
// Virtual spine item
if (virtualSpineItems) {
int virtualIndex = spineIndex - spine.size();
if (virtualIndex >= 0 && virtualIndex < static_cast<int>(virtualSpineItems->size())) {
return (*virtualSpineItems)[virtualIndex];
}
}
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex);
return "";
} }
EpubTocEntry& Epub::getTocItem(const int tocTndex) { EpubTocEntry& Epub::getTocItem(const int tocTndex) {
@ -291,6 +312,11 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
} }
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
// Skip virtual spine items
if (isVirtualSpineItem(spineIndex)) {
return -1;
}
// the toc entry should have an href that matches the spine item // the toc entry should have an href that matches the spine item
// so we can find the toc index by looking for the href // so we can find the toc index by looking for the href
for (int i = 0; i < toc.size(); i++) { for (int i = 0; i < toc.size(); i++) {
@ -302,3 +328,87 @@ int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
Serial.printf("[%lu] [EBP] TOC item not found\n", millis()); Serial.printf("[%lu] [EBP] TOC item not found\n", millis());
return -1; return -1;
} }
void Epub::markAsFootnotePage(const std::string& href) {
// Lazy initialization
if (!footnotePages) {
footnotePages = new std::unordered_set<std::string>();
}
// Extract filename from href (remove #anchor if present)
size_t hashPos = href.find('#');
std::string filename = (hashPos != std::string::npos)
? href.substr(0, hashPos)
: href;
// Extract just the filename without path
size_t lastSlash = filename.find_last_of('/');
if (lastSlash != std::string::npos) {
filename = filename.substr(lastSlash + 1);
}
footnotePages->insert(filename);
Serial.printf("[%lu] [EPUB] Marked as footnote page: %s\n", millis(), filename.c_str());
}
bool Epub::isFootnotePage(const std::string& filename) const {
if (!footnotePages) return false;
return footnotePages->find(filename) != footnotePages->end();
}
bool Epub::shouldHideFromToc(int spineIndex) const {
// Always hide virtual spine items
if (isVirtualSpineItem(spineIndex)) {
return true;
}
if (spineIndex < 0 || spineIndex >= spine.size()) {
return true;
}
const std::string& spineItem = spine[spineIndex].second;
// Extract filename from spine item
size_t lastSlash = spineItem.find_last_of('/');
std::string filename = (lastSlash != std::string::npos)
? spineItem.substr(lastSlash + 1)
: spineItem;
return isFootnotePage(filename);
}
// Virtual spine items
int Epub::addVirtualSpineItem(const std::string& path) {
// Lazy initialization
if (!virtualSpineItems) {
virtualSpineItems = new std::vector<std::string>();
}
virtualSpineItems->push_back(path);
int newIndex = spine.size() + virtualSpineItems->size() - 1;
Serial.printf("[%lu] [EPUB] Added virtual spine item: %s (index %d)\n",
millis(), path.c_str(), newIndex);
return newIndex;
}
bool Epub::isVirtualSpineItem(int spineIndex) const {
return spineIndex >= static_cast<int>(spine.size());
}
int Epub::findVirtualSpineIndex(const std::string& filename) const {
if (!virtualSpineItems) return -1;
for (size_t i = 0; i < virtualSpineItems->size(); i++) {
std::string virtualPath = (*virtualSpineItems)[i];
size_t lastSlash = virtualPath.find_last_of('/');
std::string virtualFilename = (lastSlash != std::string::npos)
? virtualPath.substr(lastSlash + 1)
: virtualPath;
if (virtualFilename == filename) {
return spine.size() + i;
}
}
return -1;
}

View File

@ -3,6 +3,7 @@
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
#include "Epub/EpubTocEntry.h" #include "Epub/EpubTocEntry.h"
@ -10,33 +11,36 @@
class ZipFile; class ZipFile;
class Epub { class Epub {
// the title read from the EPUB meta data
std::string title; std::string title;
// the cover image
std::string coverImageItem; std::string coverImageItem;
// the ncx file
std::string tocNcxItem; std::string tocNcxItem;
// where is the EPUBfile?
std::string filepath; std::string filepath;
// the spine of the EPUB file
std::vector<std::pair<std::string, std::string>> spine; std::vector<std::pair<std::string, std::string>> spine;
// the toc of the EPUB file
std::vector<EpubTocEntry> toc; std::vector<EpubTocEntry> toc;
// the base path for items in the EPUB file
std::string contentBasePath; std::string contentBasePath;
// Uniq cache key based on filepath
std::string cachePath; std::string cachePath;
// Use pointers, allocate only if needed
std::unordered_set<std::string>* footnotePages;
std::vector<std::string>* virtualSpineItems;
bool findContentOpfFile(std::string* contentOpfFile) const; bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(const std::string& contentOpfFilePath); bool parseContentOpf(const std::string& contentOpfFilePath);
bool parseTocNcxFile(); bool parseTocNcxFile();
public: public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { explicit Epub(std::string filepath, const std::string& cacheDir)
// create a cache key based on the filepath : filepath(std::move(filepath)),
footnotePages(nullptr),
virtualSpineItems(nullptr) {
cachePath = cacheDir + "/epub_" + std::to_string(std::hash<std::string>{}(this->filepath)); cachePath = cacheDir + "/epub_" + std::to_string(std::hash<std::string>{}(this->filepath));
} }
~Epub() = default;
~Epub() {
delete footnotePages;
delete virtualSpineItems;
}
std::string& getBasePath() { return contentBasePath; } std::string& getBasePath() { return contentBasePath; }
bool load(); bool load();
bool clearCache() const; bool clearCache() const;
@ -49,10 +53,19 @@ class Epub {
bool trailingNullByte = false) const; bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
bool getItemSize(const std::string& itemHref, size_t* size) const; bool getItemSize(const std::string& itemHref, size_t* size) const;
std::string& getSpineItem(int spineIndex);
std::string getSpineItem(int index) const;
int getSpineItemsCount() const; int getSpineItemsCount() const;
EpubTocEntry& getTocItem(int tocTndex); EpubTocEntry& getTocItem(int tocTndex);
int getTocItemsCount() const; int getTocItemsCount() const;
int getSpineIndexForTocIndex(int tocIndex) const; int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const; int getTocIndexForSpineIndex(int spineIndex) const;
};
void markAsFootnotePage(const std::string& href);
bool isFootnotePage(const std::string& filename) const;
bool shouldHideFromToc(int spineIndex) const;
int addVirtualSpineItem(const std::string& path);
bool isVirtualSpineItem(int spineIndex) const;
int findVirtualSpineIndex(const std::string& filename) const;
};

View File

@ -0,0 +1,12 @@
#pragma once
struct FootnoteEntry {
char number[3];
char href[64];
bool isInline;
FootnoteEntry() : isInline(false) {
number[0] = '\0';
href[0] = '\0';
}
};

View File

@ -1,17 +1,16 @@
#include "Page.h" #include "Page.h"
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <Serialization.h> #include <Serialization.h>
constexpr uint8_t PAGE_FILE_VERSION = 3; constexpr uint8_t PAGE_FILE_VERSION = 6; // Incremented
void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } void PageLine::render(GfxRenderer& renderer, const int fontId) {
block->render(renderer, fontId, xPos, yPos);
}
void PageLine::serialize(std::ostream& os) { void PageLine::serialize(std::ostream& os) {
serialization::writePod(os, xPos); serialization::writePod(os, xPos);
serialization::writePod(os, yPos); serialization::writePod(os, yPos);
// serialize TextBlock pointed to by PageLine
block->serialize(os); block->serialize(os);
} }
@ -26,21 +25,24 @@ std::unique_ptr<PageLine> PageLine::deserialize(std::istream& is) {
} }
void Page::render(GfxRenderer& renderer, const int fontId) const { void Page::render(GfxRenderer& renderer, const int fontId) const {
for (auto& element : elements) { for (int i = 0; i < elementCount; i++) {
element->render(renderer, fontId); elements[i]->render(renderer, fontId);
} }
} }
void Page::serialize(std::ostream& os) const { void Page::serialize(std::ostream& os) const {
serialization::writePod(os, PAGE_FILE_VERSION); serialization::writePod(os, PAGE_FILE_VERSION);
serialization::writePod(os, static_cast<uint32_t>(elementCount));
const uint32_t count = elements.size(); for (int i = 0; i < elementCount; i++) {
serialization::writePod(os, count);
for (const auto& el : elements) {
// Only PageLine exists currently
serialization::writePod(os, static_cast<uint8_t>(TAG_PageLine)); serialization::writePod(os, static_cast<uint8_t>(TAG_PageLine));
el->serialize(os); elements[i]->serialize(os);
}
serialization::writePod(os, static_cast<int32_t>(footnoteCount));
for (int i = 0; i < footnoteCount; i++) {
os.write(footnotes[i].number, 3);
os.write(footnotes[i].href, 64);
} }
} }
@ -57,18 +59,27 @@ std::unique_ptr<Page> Page::deserialize(std::istream& is) {
uint32_t count; uint32_t count;
serialization::readPod(is, count); serialization::readPod(is, count);
for (uint32_t i = 0; i < count; i++) { for (uint32_t i = 0; i < count && i < page->elementCapacity; i++) {
uint8_t tag; uint8_t tag;
serialization::readPod(is, tag); serialization::readPod(is, tag);
if (tag == TAG_PageLine) { if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(is); auto pl = PageLine::deserialize(is);
page->elements.push_back(std::move(pl)); page->addElement(std::move(pl));
} else { } else {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);
return nullptr; return nullptr;
} }
} }
int32_t footnoteCount;
serialization::readPod(is, footnoteCount);
page->footnoteCount = (footnoteCount < page->footnoteCapacity) ? footnoteCount : page->footnoteCapacity;
for (int i = 0; i < page->footnoteCount; i++) {
is.read(page->footnotes[i].number, 3);
is.read(page->footnotes[i].href, 64);
}
return page; return page;
} }

View File

@ -1,14 +1,16 @@
#pragma once #pragma once
#include <cstring>
#include <memory>
#include <utility> #include <utility>
#include <vector> #include <cstdlib>
#include "FootnoteEntry.h"
#include "blocks/TextBlock.h" #include "blocks/TextBlock.h"
enum PageElementTag : uint8_t { enum PageElementTag : uint8_t {
TAG_PageLine = 1, TAG_PageLine = 1,
}; };
// represents something that has been added to a page
class PageElement { class PageElement {
public: public:
int16_t xPos; int16_t xPos;
@ -19,11 +21,10 @@ class PageElement {
virtual void serialize(std::ostream& os) = 0; virtual void serialize(std::ostream& os) = 0;
}; };
// a line from a block element
class PageLine final : public PageElement { class PageLine final : public PageElement {
std::shared_ptr<TextBlock> block; std::shared_ptr<TextBlock> block;
public: public:
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos) PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId) override; void render(GfxRenderer& renderer, int fontId) override;
@ -32,10 +33,68 @@ class PageLine final : public PageElement {
}; };
class Page { class Page {
public: private:
// the list of block index and line numbers on this page std::shared_ptr<PageElement>* elements;
std::vector<std::shared_ptr<PageElement>> elements; int elementCapacity;
FootnoteEntry* footnotes;
int footnoteCapacity;
public:
int elementCount;
int footnoteCount;
Page() : elementCount(0), footnoteCount(0) {
elementCapacity = 24;
elements = new std::shared_ptr<PageElement>[elementCapacity];
footnoteCapacity = 8;
footnotes = new FootnoteEntry[footnoteCapacity];
for (int i = 0; i < footnoteCapacity; i++) {
footnotes[i].number[0] = '\0';
footnotes[i].href[0] = '\0';
}
}
~Page() {
delete[] elements;
delete[] footnotes;
}
Page(const Page&) = delete;
Page& operator=(const Page&) = delete;
void addElement(std::shared_ptr<PageElement> element) {
if (elementCount < elementCapacity) {
elements[elementCount++] = element;
}
}
void addFootnote(const char* number, const char* href) {
if (footnoteCount < footnoteCapacity) {
strncpy(footnotes[footnoteCount].number, number, 2);
footnotes[footnoteCount].number[2] = '\0';
strncpy(footnotes[footnoteCount].href, href, 63);
footnotes[footnoteCount].href[63] = '\0';
footnoteCount++;
}
}
std::shared_ptr<PageElement> getElement(int index) const {
if (index >= 0 && index < elementCount) {
return elements[index];
}
return nullptr;
}
FootnoteEntry* getFootnote(int index) {
if (index >= 0 && index < footnoteCount) {
return &footnotes[index];
}
return nullptr;
}
void render(GfxRenderer& renderer, int fontId) const; void render(GfxRenderer& renderer, int fontId) const;
void serialize(std::ostream& os) const; void serialize(std::ostream& os) const;
static std::unique_ptr<Page> deserialize(std::istream& is); static std::unique_ptr<Page> deserialize(std::istream& is);
}; };

View File

@ -4,12 +4,68 @@
#include <Serialization.h> #include <Serialization.h>
#include <fstream> #include <fstream>
#include <set>
#include "FsHelpers.h" #include "FsHelpers.h"
#include "Page.h" #include "Page.h"
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
constexpr uint8_t SECTION_FILE_VERSION = 5; constexpr uint8_t SECTION_FILE_VERSION = 6;
// Helper function to write XML-escaped text directly to file
static bool writeEscapedXml(File& file, const char* text) {
if (!text) return true;
// Use a static buffer to avoid heap allocation
static char buffer[2048];
int bufferPos = 0;
while (*text && bufferPos < sizeof(buffer) - 10) { // Leave margin for entities
unsigned char c = (unsigned char)*text;
// Only escape the 5 XML special characters
if (c == '<') {
if (bufferPos + 4 < sizeof(buffer)) {
memcpy(&buffer[bufferPos], "&lt;", 4);
bufferPos += 4;
}
} else if (c == '>') {
if (bufferPos + 4 < sizeof(buffer)) {
memcpy(&buffer[bufferPos], "&gt;", 4);
bufferPos += 4;
}
} else if (c == '&') {
if (bufferPos + 5 < sizeof(buffer)) {
memcpy(&buffer[bufferPos], "&amp;", 5);
bufferPos += 5;
}
} else if (c == '"') {
if (bufferPos + 6 < sizeof(buffer)) {
memcpy(&buffer[bufferPos], "&quot;", 6);
bufferPos += 6;
}
} else if (c == '\'') {
if (bufferPos + 6 < sizeof(buffer)) {
memcpy(&buffer[bufferPos], "&apos;", 6);
bufferPos += 6;
}
} else {
// Keep everything else (include UTF8)
// This preserves accented characters like é, è, à, etc.
buffer[bufferPos++] = (char)c;
}
text++;
}
buffer[bufferPos] = '\0';
// Write all at once
size_t written = file.write((const uint8_t*)buffer, bufferPos);
file.flush();
return written == bufferPos;
}
void Section::onPageComplete(std::unique_ptr<Page> page) { void Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin";
@ -96,7 +152,6 @@ void Section::setupCacheDir() const {
SD.mkdir(cachePath.c_str()); SD.mkdir(cachePath.c_str());
} }
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
bool Section::clearCache() const { bool Section::clearCache() const {
if (!SD.exists(cachePath.c_str())) { if (!SD.exists(cachePath.c_str())) {
Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis()); Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis());
@ -117,9 +172,29 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
const bool extraParagraphSpacing) { const bool extraParagraphSpacing) {
const auto localPath = epub->getSpineItem(spineIndex); const auto localPath = epub->getSpineItem(spineIndex);
// TODO: Should we get rid of this file all together? // Check if it's a virtual spine item
// It currently saves us a bit of memory by allowing for all the inflation bits to be released if (epub->isVirtualSpineItem(spineIndex)) {
// before loading the XML parser Serial.printf("[%lu] [SCT] Processing virtual spine item: %s\n", millis(), localPath.c_str());
const auto sdPath = "/sd" + localPath;
ChapterHtmlSlimParser visitor(sdPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight,
marginBottom, marginLeft, extraParagraphSpacing,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); },
cachePath);
bool success = visitor.parseAndBuildPages();
if (!success) {
Serial.printf("[%lu] [SCT] Failed to parse virtual file\n", millis());
return false;
}
writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing);
return true;
}
// Normal file
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true); File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true);
bool success = epub->readItemContentsToStream(localPath, f, 1024); bool success = epub->readItemContentsToStream(localPath, f, 1024);
@ -136,15 +211,181 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight, ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight,
marginBottom, marginLeft, extraParagraphSpacing, marginBottom, marginLeft, extraParagraphSpacing,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }); [this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); },
cachePath);
// Track which inline footnotes AND paragraph notes are actually referenced in this file
std::set<std::string> rewrittenInlineIds;
int noterefCount = 0;
visitor.setNoterefCallback([this, &noterefCount, &rewrittenInlineIds](Noteref& noteref) {
Serial.printf("[%lu] [SCT] Callback noteref: %s -> %s\n", millis(), noteref.number, noteref.href);
// Extract the ID from the href for tracking
std::string href(noteref.href);
// Check if this was rewritten to an inline or paragraph note
if (href.find("inline_") == 0 || href.find("pnote_") == 0) {
size_t underscorePos = href.find('_');
size_t dotPos = href.find('.');
if (underscorePos != std::string::npos && dotPos != std::string::npos) {
std::string noteId = href.substr(underscorePos + 1, dotPos - underscorePos - 1);
rewrittenInlineIds.insert(noteId);
Serial.printf("[%lu] [SCT] Marked note as rewritten: %s\n",
millis(), noteId.c_str());
}
}else {
// Normal external footnote
epub->markAsFootnotePage(noteref.href);
}
noterefCount++;
});
// Parse and build pages (inline hrefs are rewritten automatically inside parser)
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
SD.remove(tmpHtmlPath.c_str()); SD.remove(tmpHtmlPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis()); Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis());
return false; return false;
} }
// NOW generate inline footnote HTML files ONLY for rewritten ones
Serial.printf("[%lu] [SCT] Found %d inline footnotes, %d were referenced\n",
millis(), visitor.inlineFootnoteCount, rewrittenInlineIds.size());
for (int i = 0; i < visitor.inlineFootnoteCount; i++) {
const char* inlineId = visitor.inlineFootnotes[i].id;
const char* inlineText = visitor.inlineFootnotes[i].text;
// Only generate if this inline footnote was actually referenced
if (rewrittenInlineIds.find(std::string(inlineId)) == rewrittenInlineIds.end()) {
Serial.printf("[%lu] [SCT] Skipping unreferenced inline footnote: %s\n",
millis(), inlineId);
continue;
}
// Verify that the text exists
if (!inlineText || strlen(inlineText) == 0) {
Serial.printf("[%lu] [SCT] Skipping empty inline footnote: %s\n", millis(), inlineId);
continue;
}
Serial.printf("[%lu] [SCT] Processing inline footnote: %s (len=%d)\n",
millis(), inlineId, strlen(inlineText));
char inlineFilename[64];
snprintf(inlineFilename, sizeof(inlineFilename), "inline_%s.html", inlineId);
// Store in main cache dir, not section cache dir
std::string fullPath = epub->getCachePath() + "/" + std::string(inlineFilename);
Serial.printf("[%lu] [SCT] Generating inline file: %s\n", millis(), fullPath.c_str());
File file = SD.open(fullPath.c_str(), FILE_WRITE, true);
if (file) {
// valid XML declaration and encoding
file.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
file.println("<!DOCTYPE html>");
file.println("<html xmlns=\"http://www.w3.org/1999/xhtml\">");
file.println("<head>");
file.println("<meta charset=\"UTF-8\"/>");
file.println("<title>Footnote</title>");
file.println("</head>");
file.println("<body>");
// Paragraph with content
file.print("<p id=\"");
file.print(inlineId);
file.print("\">");
if (!writeEscapedXml(file, inlineText)) {
Serial.printf("[%lu] [SCT] Warning: writeEscapedXml may have failed\n", millis());
}
file.println("</p>");
file.println("</body>");
file.println("</html>");
file.close();
Serial.printf("[%lu] [SCT] Generated inline footnote file\n", millis());
int virtualIndex = epub->addVirtualSpineItem(fullPath);
Serial.printf("[%lu] [SCT] Added virtual spine item at index %d\n", millis(), virtualIndex);
// Mark as footnote page
char newHref[128];
snprintf(newHref, sizeof(newHref), "%s#%s", inlineFilename, inlineId);
epub->markAsFootnotePage(newHref);
} else {
Serial.printf("[%lu] [SCT] Failed to create inline file\n", millis());
}
}
// Generate paragraph note HTML files
Serial.printf("[%lu] [SCT] Found %d paragraph notes\n", millis(), visitor.paragraphNoteCount);
for (int i = 0; i < visitor.paragraphNoteCount; i++) {
const char* pnoteId = visitor.paragraphNotes[i].id;
const char* pnoteText = visitor.paragraphNotes[i].text;
if (!pnoteText || strlen(pnoteText) == 0) {
continue;
}
// Check if this paragraph note was referenced
if (rewrittenInlineIds.find(std::string(pnoteId)) == rewrittenInlineIds.end()) {
Serial.printf("[%lu] [SCT] Skipping unreferenced paragraph note: %s\n", millis(), pnoteId);
continue;
}
// Create filename: pnote_rnote1.html
char pnoteFilename[64];
snprintf(pnoteFilename, sizeof(pnoteFilename), "pnote_%s.html", pnoteId);
std::string fullPath = epub->getCachePath() + "/" + std::string(pnoteFilename);
Serial.printf("[%lu] [SCT] Generating paragraph note file: %s\n", millis(), fullPath.c_str());
File file = SD.open(fullPath.c_str(), FILE_WRITE, true);
if (file) {
file.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
file.println("<!DOCTYPE html>");
file.println("<html xmlns=\"http://www.w3.org/1999/xhtml\">");
file.println("<head>");
file.println("<meta charset=\"UTF-8\"/>");
file.println("<title>Note</title>");
file.println("</head>");
file.println("<body>");
file.print("<p id=\"");
file.print(pnoteId);
file.print("\">");
if (!writeEscapedXml(file, pnoteText)) {
Serial.printf("[%lu] [SCT] Warning: writeEscapedXml may have failed\n", millis());
}
file.println("</p>");
file.println("</body>");
file.println("</html>");
file.close();
Serial.printf("[%lu] [SCT] Generated paragraph note file\n", millis());
int virtualIndex = epub->addVirtualSpineItem(fullPath);
Serial.printf("[%lu] [SCT] Added virtual spine item at index %d\n", millis(), virtualIndex);
char newHref[128];
snprintf(newHref, sizeof(newHref), "%s#%s", pnoteFilename, pnoteId);
epub->markAsFootnotePage(newHref);
}
}
Serial.printf("[%lu] [SCT] Total noterefs found: %d\n", millis(), noterefCount);
writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing); writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing);
return true; return true;

View File

@ -2,6 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SD.h>
#include <expat.h> #include <expat.h>
#include "../Page.h" #include "../Page.h"
@ -27,7 +28,6 @@ constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]);
bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; } bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; }
// given the start and end of a tag, check to see if it matches a known tag
bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) { bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) {
for (int i = 0; i < possible_tag_count; i++) { for (int i = 0; i < possible_tag_count; i++) {
if (strcmp(tag_name, possible_tags[i]) == 0) { if (strcmp(tag_name, possible_tags[i]) == 0) {
@ -37,23 +37,207 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
return false; return false;
} }
// start a new text block if needed 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;
}
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::BLOCK_STYLE style) { void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::BLOCK_STYLE style) {
if (currentTextBlock) { if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it
if (currentTextBlock->isEmpty()) { if (currentTextBlock->isEmpty()) {
currentTextBlock->setStyle(style); currentTextBlock->setStyle(style);
return; return;
} }
makePages(); makePages();
} }
currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing)); currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing));
} }
void ChapterHtmlSlimParser::addFootnoteToCurrentPage(const char* number, const char* href) {
if (currentPageFootnoteCount >= 32) return;
Serial.printf("[%lu] [ADDFT] Adding footnote: num=%s, href=%s\n", millis(), number, href);
// Copy number
strncpy(currentPageFootnotes[currentPageFootnoteCount].number, number, 2);
currentPageFootnotes[currentPageFootnoteCount].number[2] = '\0';
// Check if this is an inline footnote reference
const char* hashPos = strchr(href, '#');
if (hashPos) {
const char* inlineId = hashPos + 1; // Skip the '#'
// Check if we have this inline footnote
bool foundInline = false;
for (int i = 0; i < inlineFootnoteCount; i++) {
if (strcmp(inlineFootnotes[i].id, inlineId) == 0) {
// This is an inline footnote! Rewrite the href
char rewrittenHref[64];
snprintf(rewrittenHref, sizeof(rewrittenHref), "inline_%s.html#%s", inlineId, inlineId);
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, rewrittenHref, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
Serial.printf("[%lu] [ADDFT] Rewrote inline href to: %s\n", millis(), rewrittenHref);
foundInline = true;
break;
}
}
//Check if we have this as a paragraph note
if (!foundInline) {
for (int i = 0; i < paragraphNoteCount; i++) {
if (strcmp(paragraphNotes[i].id, inlineId) == 0) {
char rewrittenHref[64];
snprintf(rewrittenHref, sizeof(rewrittenHref), "pnote_%s.html#%s", inlineId, inlineId);
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, rewrittenHref, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
Serial.printf("[%lu] [ADDFT] Rewrote paragraph note href to: %s\n", millis(), rewrittenHref);
foundInline = true;
break;
}
}
}
if (!foundInline) {
// Normal href, just copy it
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, href, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
}
} else {
// No anchor, just copy
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, href, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
}
currentPageFootnoteCount++;
Serial.printf("[%lu] [ADDFT] Stored as: num=%s, href=%s\n",
millis(),
currentPageFootnotes[currentPageFootnoteCount-1].number,
currentPageFootnotes[currentPageFootnoteCount-1].href);
}
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) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
(void)atts;
// ============================================================================
// PASS 1: Detect and collect <p class="note">
// ============================================================================
if (strcmp(name, "p") == 0 && self->isPass1CollectingAsides) {
const char* classAttr = getAttribute(atts, "class");
if (classAttr && (strcmp(classAttr, "note") == 0 || strstr(classAttr, "note"))) {
Serial.printf("[%lu] [PNOTE] Found paragraph note (pass1=1)\n", millis());
self->insideParagraphNote = true;
self->paragraphNoteDepth = self->depth;
self->currentParagraphNoteTextLen = 0;
self->currentParagraphNoteText[0] = '\0';
self->currentParagraphNoteId[0] = '\0';
self->depth += 1;
return;
}
}
// Inside paragraph note in Pass 1, look for <a id="rnoteX">
if (self->insideParagraphNote && self->isPass1CollectingAsides && strcmp(name, "a") == 0) {
const char* id = getAttribute(atts, "id");
if (id && strncmp(id, "rnote", 5) == 0) {
strncpy(self->currentParagraphNoteId, id, 15);
self->currentParagraphNoteId[15] = '\0';
Serial.printf("[%lu] [PNOTE] Found note ID: %s\n", millis(), id);
}
self->depth += 1;
return;
}
// ============================================================================
// PASS 1: Detect and collect <aside epub:type="footnote">
// ============================================================================
if (strcmp(name, "aside") == 0) {
const char* epubType = getAttribute(atts, "epub:type");
const char* id = getAttribute(atts, "id");
if (epubType && strcmp(epubType, "footnote") == 0 && id) {
if (self->isPass1CollectingAsides) {
// Pass 1: Collect aside
Serial.printf("[%lu] [ASIDE] Found inline footnote: id=%s (pass1=%d)\n",
millis(), id, self->isPass1CollectingAsides);
self->insideAsideFootnote = true;
self->asideDepth = self->depth;
self->currentAsideTextLen = 0;
self->currentAsideText[0] = '\0';
strncpy(self->currentAsideId, id, 2);
self->currentAsideId[2] = '\0';
} else {
// Pass 2: Skip the aside (we already have it from Pass 1)
Serial.printf("[%lu] [ASIDE] Skipping aside in Pass 2: id=%s\n", millis(), id);
// Find the inline footnote text
for (int i = 0; i < self->inlineFootnoteCount; i++) {
if (strcmp(self->inlineFootnotes[i].id, id) == 0 &&
self->inlineFootnotes[i].text) {
// Output the footnote text as normal text
const char* text = self->inlineFootnotes[i].text;
int textLen = strlen(text);
// Process it through characterData
self->characterData(self, text, textLen);
Serial.printf("[%lu] [ASIDE] Rendered aside text: %.80s...\n",
millis(), text);
break;
}
}
// Skip the aside element itself
self->skipUntilDepth = self->depth;
}
self->depth += 1;
return;
}
}
// ============================================================================
// PASS 1: Skip everything else
// ============================================================================
if (self->isPass1CollectingAsides) {
self->depth += 1;
return;
}
// ============================================================================
// PASS 2: Skip <p class="note"> (we already have them from Pass 1)
// ============================================================================
if (strcmp(name, "p") == 0) {
const char* classAttr = getAttribute(atts, "class");
if (classAttr && (strcmp(classAttr, "note") == 0 || strstr(classAttr, "note"))) {
Serial.printf("[%lu] [PNOTE] Skipping paragraph note in Pass 2\n", millis());
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
}
// ============================================================================
// PASS 2: Normal parsing
// ============================================================================
// Middle of skip // Middle of skip
if (self->skipUntilDepth < self->depth) { if (self->skipUntilDepth < self->depth) {
@ -61,15 +245,67 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return; return;
} }
// Rest of startElement logic for pass 2...
if (strcmp(name, "a") == 0) {
const char* epubType = getAttribute(atts, "epub:type");
const char* href = getAttribute(atts, "href");
// Detect epub:type="noteref" OR href="#rnoteX" pattern
bool isNoteref = (epubType && strcmp(epubType, "noteref") == 0);
// Also detect links with href starting with "#rnote" (reverse note pattern)
if (!isNoteref && href && href[0] == '#' && strncmp(href + 1, "rnote", 5) == 0) {
isNoteref = true;
Serial.printf("[%lu] [NOTEREF] Detected reverse note pattern: href=%s\n", millis(), href);
}
if (isNoteref) {
Serial.printf("[%lu] [NOTEREF] Found noteref: href=%s\n", millis(), href ? href : "null");
// Flush any pending word before starting noteref collection
// This ensures proper word order in the text flow
if (self->partWordBufferIndex > 0) {
EpdFontStyle fontStyle = REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) {
fontStyle = BOLD;
} else if (self->italicUntilDepth < self->depth) {
fontStyle = ITALIC;
}
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
self->partWordBufferIndex = 0;
}
self->insideNoteref = true;
self->currentNoterefTextLen = 0;
self->currentNoterefText[0] = '\0';
if (href) {
self->currentNoterefHrefLen = 0;
const char* src = href;
while (*src && self->currentNoterefHrefLen < 127) {
self->currentNoterefHref[self->currentNoterefHrefLen++] = *src++;
}
self->currentNoterefHref[self->currentNoterefHrefLen] = '\0';
} else {
self->currentNoterefHref[0] = '\0';
self->currentNoterefHrefLen = 0;
}
self->depth += 1;
return;
}
}
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// TODO: Start processing image tags
self->skipUntilDepth = self->depth; self->skipUntilDepth = self->depth;
self->depth += 1; self->depth += 1;
return; return;
} }
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
// start skip
self->skipUntilDepth = self->depth; self->skipUntilDepth = self->depth;
self->depth += 1; self->depth += 1;
return; return;
@ -96,7 +332,81 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char* s, const int len) { void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
// Middle of skip //Collect paragraph note text in Pass 1
if (self->insideParagraphNote && self->isPass1CollectingAsides) {
for (int i = 0; i < len; i++) {
if (self->currentParagraphNoteTextLen >= self->MAX_PNOTE_BUFFER - 2) {
if (self->currentParagraphNoteTextLen == self->MAX_PNOTE_BUFFER - 2) {
Serial.printf("[%lu] [PNOTE] WARNING: Note text truncated at %d chars\n",
millis(), self->MAX_PNOTE_BUFFER - 2);
}
break;
}
unsigned char c = (unsigned char)s[i];
if (isWhitespace(c)) {
if (self->currentParagraphNoteTextLen > 0 &&
self->currentParagraphNoteText[self->currentParagraphNoteTextLen - 1] != ' ') {
self->currentParagraphNoteText[self->currentParagraphNoteTextLen++] = ' ';
}
} else if (c >= 32 || c >= 0x80) { // Accept printable ASCII AND UTF-8
self->currentParagraphNoteText[self->currentParagraphNoteTextLen++] = c;
}
}
self->currentParagraphNoteText[self->currentParagraphNoteTextLen] = '\0';
return;
}
// If inside aside, collect the text ONLY in pass 1
if (self->insideAsideFootnote) {
if (!self->isPass1CollectingAsides) {
return;
}
for (int i = 0; i < len; i++) {
if (self->currentAsideTextLen >= self->MAX_ASIDE_BUFFER - 2) {
if (self->currentAsideTextLen == self->MAX_ASIDE_BUFFER - 2) {
Serial.printf("[%lu] [ASIDE] WARNING: Footnote text truncated at %d chars (id=%s)\n",
millis(), self->MAX_ASIDE_BUFFER - 2, self->currentAsideId);
}
break;
}
unsigned char c = (unsigned char)s[i]; // Cast to unsigned char
if (isWhitespace(c)) {
if (self->currentAsideTextLen > 0 &&
self->currentAsideText[self->currentAsideTextLen - 1] != ' ') {
self->currentAsideText[self->currentAsideTextLen++] = ' ';
}
} else if (c >= 32 || c >= 0x80) { // Accept printable ASCII AND UTF-8 bytes
self->currentAsideText[self->currentAsideTextLen++] = c;
}
// Skip control characters (0x00-0x1F) except whitespace
}
self->currentAsideText[self->currentAsideTextLen] = '\0';
return;
}
// During pass 1, skip all other content
if (self->isPass1CollectingAsides) {
return;
}
// Rest of characterData logic for pass 2...
if (self->insideNoteref) {
for (int i = 0; i < len; i++) {
unsigned char c = (unsigned char)s[i];
// Skip whitespace and brackets []
if (!isWhitespace(c) && c != '[' && c != ']' && self->currentNoterefTextLen < 15) {
self->currentNoterefText[self->currentNoterefTextLen++] = c;
self->currentNoterefText[self->currentNoterefTextLen] = '\0';
}
}
return;
}
if (self->skipUntilDepth < self->depth) { if (self->skipUntilDepth < self->depth) {
return; return;
} }
@ -112,17 +422,14 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
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
if (self->partWordBufferIndex > 0) { if (self->partWordBufferIndex > 0) {
self->partWordBuffer[self->partWordBufferIndex] = '\0'; self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
self->partWordBufferIndex = 0; self->partWordBufferIndex = 0;
} }
// Skip the whitespace char
continue; continue;
} }
// If we're about to run out of space, then cut the word off and start a new one
if (self->partWordBufferIndex >= MAX_WORD_SIZE) { if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
self->partWordBuffer[self->partWordBufferIndex] = '\0'; self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
@ -135,13 +442,143 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
(void)name;
//Closing paragraph note in Pass 1
if (strcmp(name, "p") == 0 && self->insideParagraphNote &&
self->depth - 1 == self->paragraphNoteDepth) {
if (self->isPass1CollectingAsides &&
self->currentParagraphNoteTextLen > 0 &&
self->paragraphNoteCount < 32 &&
self->currentParagraphNoteId[0] != '\0') {
// Copy ID
strncpy(self->paragraphNotes[self->paragraphNoteCount].id,
self->currentParagraphNoteId, 15);
self->paragraphNotes[self->paragraphNoteCount].id[15] = '\0';
// Allocate memory for text
size_t textLen = strlen(self->currentParagraphNoteText);
self->paragraphNotes[self->paragraphNoteCount].text =
static_cast<char*>(malloc(textLen + 1));
if (self->paragraphNotes[self->paragraphNoteCount].text) {
strcpy(self->paragraphNotes[self->paragraphNoteCount].text,
self->currentParagraphNoteText);
Serial.printf("[%lu] [PNOTE] Stored: %s -> %.80s... (allocated %d bytes)\n",
millis(), self->currentParagraphNoteId,
self->currentParagraphNoteText, textLen + 1);
self->paragraphNoteCount++;
}
}
self->insideParagraphNote = false;
self->depth -= 1;
return;
}
// Closing aside - handle differently for Pass 1 vs Pass 2
if (strcmp(name, "aside") == 0 && self->insideAsideFootnote &&
self->depth - 1 == self->asideDepth) {
// Store footnote ONLY in Pass 1
if (self->isPass1CollectingAsides &&
self->currentAsideTextLen > 0 &&
self->inlineFootnoteCount < 16) {
// Copy ID (max 2 digits)
strncpy(self->inlineFootnotes[self->inlineFootnoteCount].id,
self->currentAsideId, 2);
self->inlineFootnotes[self->inlineFootnoteCount].id[2] = '\0';
// DYNAMIC ALLOCATION: allocate exactly the needed size + 1
size_t textLen = strlen(self->currentAsideText);
self->inlineFootnotes[self->inlineFootnoteCount].text =
static_cast<char*>(malloc(textLen + 1));
if (self->inlineFootnotes[self->inlineFootnoteCount].text) {
strcpy(self->inlineFootnotes[self->inlineFootnoteCount].text,
self->currentAsideText);
Serial.printf("[%lu] [ASIDE] Stored: %s -> %.80s... (allocated %d bytes)\n",
millis(), self->currentAsideId, self->currentAsideText, textLen + 1);
self->inlineFootnoteCount++;
} else {
Serial.printf("[%lu] [ASIDE] ERROR: Failed to allocate %d bytes for footnote %s\n",
millis(), textLen + 1, self->currentAsideId);
}
}
// Reset state AFTER processing
self->insideAsideFootnote = false;
self->depth -= 1;
return;
}
// During pass 1, skip all other processing
if (self->isPass1CollectingAsides) {
self->depth -= 1;
return;
}
// Rest of endElement logic for pass 2 - MODIFIED
if (strcmp(name, "a") == 0 && self->insideNoteref) {
self->insideNoteref = false;
if (self->currentNoterefTextLen > 0) {
Serial.printf("[%lu] [NOTEREF] %s -> %s\n", millis(),
self->currentNoterefText,
self->currentNoterefHref);
// Add footnote first (this does the rewriting)
self->addFootnoteToCurrentPage(self->currentNoterefText, self->currentNoterefHref);
// Then call callback with the REWRITTEN href from currentPageFootnotes
if (self->noterefCallback && self->currentPageFootnoteCount > 0) {
Noteref noteref;
strncpy(noteref.number, self->currentNoterefText, 15);
noteref.number[15] = '\0';
// Use the STORED href which has been rewritten
FootnoteEntry* lastFootnote = &self->currentPageFootnotes[self->currentPageFootnoteCount - 1];
strncpy(noteref.href, lastFootnote->href, 127);
noteref.href[127] = '\0';
self->noterefCallback(noteref);
}
// Ensure [1] appears inline after the word it references
EpdFontStyle fontStyle = REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) {
fontStyle = BOLD;
} else if (self->italicUntilDepth < self->depth) {
fontStyle = ITALIC;
}
// Format the noteref text with brackets
char formattedNoteref[32];
snprintf(formattedNoteref, sizeof(formattedNoteref), "[%s]", self->currentNoterefText);
// Add it as a word to the current text block
if (self->currentTextBlock) {
self->currentTextBlock->addWord(formattedNoteref, fontStyle);
}
}
self->currentNoterefTextLen = 0;
self->currentNoterefText[0] = '\0';
self->currentNoterefHrefLen = 0;
self->currentNoterefHref[0] = '\0';
self->depth -= 1;
return;
}
if (self->partWordBufferIndex > 0) { if (self->partWordBufferIndex > 0) {
// Only flush out part word buffer if we're closing a block tag or are at the top of the HTML file.
// We don't want to flush out content when closing inline tags like <span>.
// Currently this also flushes out on closing <b> and <i> tags, but they are line tags so that shouldn't happen,
// text styling needs to be overhauled to fix it.
const bool shouldBreakText = const bool shouldBreakText =
matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS) ||
matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1; matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1;
@ -164,49 +601,57 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->depth -= 1; self->depth -= 1;
// Leaving skip
if (self->skipUntilDepth == self->depth) { if (self->skipUntilDepth == self->depth) {
self->skipUntilDepth = INT_MAX; self->skipUntilDepth = INT_MAX;
} }
// Leaving bold
if (self->boldUntilDepth == self->depth) { if (self->boldUntilDepth == self->depth) {
self->boldUntilDepth = INT_MAX; self->boldUntilDepth = INT_MAX;
} }
// Leaving italic
if (self->italicUntilDepth == self->depth) { if (self->italicUntilDepth == self->depth) {
self->italicUntilDepth = INT_MAX; self->italicUntilDepth = INT_MAX;
} }
} }
bool ChapterHtmlSlimParser::parseAndBuildPages() { bool ChapterHtmlSlimParser::parseAndBuildPages() {
startNewTextBlock(TextBlock::JUSTIFIED); // ============================================================================
// PASS 1: Extract all inline footnotes (aside elements) FIRST
// ============================================================================
Serial.printf("[%lu] [PARSER] === PASS 1: Extracting inline footnotes ===\n", millis());
const XML_Parser parser = XML_ParserCreate(nullptr); // Reset state for pass 1
int done; depth = 0;
skipUntilDepth = INT_MAX;
insideAsideFootnote = false;
insideParagraphNote = false;
inlineFootnoteCount = 0;
paragraphNoteCount = 0;
isPass1CollectingAsides = true;
if (!parser) { XML_Parser parser1 = XML_ParserCreate(nullptr);
if (!parser1) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for parser\n", millis()); Serial.printf("[%lu] [EHP] Couldn't allocate memory for parser\n", millis());
return false; return false;
} }
XML_SetUserData(parser, this); XML_SetUserData(parser1, this);
XML_SetElementHandler(parser, startElement, endElement); XML_SetElementHandler(parser1, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData); XML_SetCharacterDataHandler(parser1, characterData);
FILE* file = fopen(filepath, "r"); FILE* file = fopen(filepath, "r");
if (!file) { if (!file) {
Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath); Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath);
XML_ParserFree(parser); XML_ParserFree(parser1);
return false; return false;
} }
int done;
do { do {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser1, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis()); Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis());
XML_ParserFree(parser); XML_ParserFree(parser1);
fclose(file); fclose(file);
return false; return false;
} }
@ -215,29 +660,113 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
if (ferror(file)) { if (ferror(file)) {
Serial.printf("[%lu] [EHP] File read error\n", millis()); Serial.printf("[%lu] [EHP] File read error\n", millis());
XML_ParserFree(parser); XML_ParserFree(parser1);
fclose(file); fclose(file);
return false; return false;
} }
done = feof(file); done = feof(file);
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser1, static_cast<int>(len), done) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(),
XML_ErrorString(XML_GetErrorCode(parser))); XML_GetCurrentLineNumber(parser1),
XML_ParserFree(parser); XML_ErrorString(XML_GetErrorCode(parser1)));
XML_ParserFree(parser1);
fclose(file); fclose(file);
return false; return false;
} }
} while (!done); } while (!done);
XML_ParserFree(parser); XML_ParserFree(parser1);
fclose(file);
Serial.printf("[%lu] [PARSER] Pass 1 complete: found %d inline footnotes\n",
millis(), inlineFootnoteCount);
for (int i = 0; i < inlineFootnoteCount; i++) {
Serial.printf("[%lu] [PARSER] - %s: %.80s\n",
millis(), inlineFootnotes[i].id, inlineFootnotes[i].text);
}
// ============================================================================
// PASS 2: Build pages with inline footnotes already available
// ============================================================================
Serial.printf("[%lu] [PARSER] === PASS 2: Building pages ===\n", millis());
// Reset parser state for pass 2
depth = 0;
skipUntilDepth = INT_MAX;
boldUntilDepth = INT_MAX;
italicUntilDepth = INT_MAX;
partWordBufferIndex = 0;
insideNoteref = false;
insideAsideFootnote = false;
currentPageFootnoteCount = 0;
isPass1CollectingAsides = false;
startNewTextBlock(TextBlock::JUSTIFIED);
const XML_Parser parser2 = XML_ParserCreate(nullptr);
if (!parser2) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for parser\n", millis());
return false;
}
XML_SetUserData(parser2, this);
XML_SetElementHandler(parser2, startElement, endElement);
XML_SetCharacterDataHandler(parser2, characterData);
file = fopen(filepath, "r");
if (!file) {
Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath);
XML_ParserFree(parser2);
return false;
}
do {
void* const buf = XML_GetBuffer(parser2, 1024);
if (!buf) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis());
XML_ParserFree(parser2);
fclose(file);
return false;
}
const size_t len = fread(buf, 1, 1024, file);
if (ferror(file)) {
Serial.printf("[%lu] [EHP] File read error\n", millis());
XML_ParserFree(parser2);
fclose(file);
return false;
}
done = feof(file);
if (XML_ParseBuffer(parser2, static_cast<int>(len), done) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(),
XML_GetCurrentLineNumber(parser2),
XML_ErrorString(XML_GetErrorCode(parser2)));
XML_ParserFree(parser2);
fclose(file);
return false;
}
} while (!done);
XML_ParserFree(parser2);
fclose(file); fclose(file);
// Process last page if there is still text // Process last page if there is still text
if (currentTextBlock) { if (currentTextBlock) {
makePages(); makePages();
completePageFn(std::move(currentPage));
if (currentPage) {
for (int i = 0; i < currentPageFootnoteCount; i++) {
currentPage->addFootnote(currentPageFootnotes[i].number, currentPageFootnotes[i].href);
}
currentPageFootnoteCount = 0;
completePageFn(std::move(currentPage));
}
currentPage.reset(); currentPage.reset();
currentTextBlock.reset(); currentTextBlock.reset();
} }
@ -250,13 +779,24 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom; const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom;
if (currentPageNextY + lineHeight > pageHeight) { if (currentPageNextY + lineHeight > pageHeight) {
if (currentPage) {
for (int i = 0; i < currentPageFootnoteCount; i++) {
currentPage->addFootnote(currentPageFootnotes[i].number, currentPageFootnotes[i].href);
}
currentPageFootnoteCount = 0;
}
completePageFn(std::move(currentPage)); completePageFn(std::move(currentPage));
currentPage.reset(new Page()); currentPage.reset(new Page());
currentPageNextY = marginTop; currentPageNextY = marginTop;
} }
currentPage->elements.push_back(std::make_shared<PageLine>(line, marginLeft, currentPageNextY)); if (currentPage && currentPage->elementCount < 24) {
currentPageNextY += lineHeight; currentPage->addElement(std::make_shared<PageLine>(line, marginLeft, currentPageNextY));
currentPageNextY += lineHeight;
} else {
Serial.printf("[%lu] [EHP] WARNING: Page element capacity reached, skipping element\n", millis());
}
} }
void ChapterHtmlSlimParser::makePages() { void ChapterHtmlSlimParser::makePages() {
@ -279,3 +819,4 @@ void ChapterHtmlSlimParser::makePages() {
currentPageNextY += lineHeight / 2; currentPageNextY += lineHeight / 2;
} }
} }

View File

@ -1,19 +1,55 @@
#pragma once #pragma once
#include <expat.h> #include <expat.h>
#include <climits> #include <climits>
#include <cstring>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include "../ParsedText.h" #include "../ParsedText.h"
#include "../blocks/TextBlock.h" #include "../blocks/TextBlock.h"
#include "../FootnoteEntry.h"
class Page; class Page;
class GfxRenderer; class GfxRenderer;
#define MAX_WORD_SIZE 200 #define MAX_WORD_SIZE 200
struct Noteref {
char number[16];
char href[128];
};
// Struct to store collected inline footnotes (aside elements)
struct InlineFootnote {
char id[3];
char* text;
InlineFootnote() : text(nullptr) {
id[0] = '\0';
}
};
// Struct to store collected inline footnotes from <p class="note">
struct ParagraphNote {
char id[16]; // ID from <a id="rnote1">
char* text; // Pointer to dynamically allocated text
ParagraphNote() : text(nullptr) {
id[0] = '\0';
}
~ParagraphNote() {
if (text) {
free(text);
text = nullptr;
}
}
ParagraphNote(const ParagraphNote&) = delete;
ParagraphNote& operator=(const ParagraphNote&) = delete;
};
class ChapterHtmlSlimParser { class ChapterHtmlSlimParser {
const char* filepath; const char* filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
@ -22,8 +58,6 @@ class ChapterHtmlSlimParser {
int skipUntilDepth = INT_MAX; int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX;
int italicUntilDepth = INT_MAX; int italicUntilDepth = INT_MAX;
// buffer for building up words from characters, will auto break if longer than this
// leave one char at end for null pointer
char partWordBuffer[MAX_WORD_SIZE + 1] = {}; char partWordBuffer[MAX_WORD_SIZE + 1] = {};
int partWordBufferIndex = 0; int partWordBufferIndex = 0;
std::unique_ptr<ParsedText> currentTextBlock = nullptr; std::unique_ptr<ParsedText> currentTextBlock = nullptr;
@ -37,20 +71,67 @@ class ChapterHtmlSlimParser {
int marginLeft; int marginLeft;
bool extraParagraphSpacing; bool extraParagraphSpacing;
// Noteref tracking
bool insideNoteref = false;
char currentNoterefText[16] = {0};
int currentNoterefTextLen = 0;
char currentNoterefHref[128] = {0};
int currentNoterefHrefLen = 0;
std::function<void(Noteref&)> noterefCallback = nullptr;
// Footnote tracking for current page
FootnoteEntry currentPageFootnotes[32];
int currentPageFootnoteCount = 0;
// Inline footnotes (aside) tracking
bool insideAsideFootnote = false;
int asideDepth = 0;
char currentAsideId[3] = {0};
//Paragraph note tracking
bool insideParagraphNote = false;
int paragraphNoteDepth = 0;
char currentParagraphNoteId[16] = {0};
static constexpr int MAX_PNOTE_BUFFER = 512;
char currentParagraphNoteText[MAX_PNOTE_BUFFER] = {0};
int currentParagraphNoteTextLen = 0;
// Temporary buffer for accumulation, will be copied to dynamic allocation
static constexpr int MAX_ASIDE_BUFFER = 2048;
char currentAsideText[MAX_ASIDE_BUFFER] = {0};
int currentAsideTextLen = 0;
// Flag to indicate we're in Pass 1 (collecting asides only)
bool isPass1CollectingAsides = false;
// Cache dir path for generating HTML files
std::string cacheDir;
void addFootnoteToCurrentPage(const char* number, const char* href);
void startNewTextBlock(TextBlock::BLOCK_STYLE style); void startNewTextBlock(TextBlock::BLOCK_STYLE style);
void makePages(); void makePages();
// XML callbacks // XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void XMLCALL characterData(void* userData, const XML_Char* s, int len); static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL endElement(void* userData, const XML_Char* name);
public: public:
// inline footnotes
InlineFootnote inlineFootnotes[16];
int inlineFootnoteCount = 0;
//paragraph notes
ParagraphNote paragraphNotes[32];
int paragraphNoteCount = 0;
explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId, explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const int marginTop, const int marginRight, const float lineCompression, const int marginTop, const int marginRight,
const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing,
const std::function<void(std::unique_ptr<Page>)>& completePageFn) const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::string& cacheDir = "")
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
completePageFn(completePageFn),
fontId(fontId), fontId(fontId),
lineCompression(lineCompression), lineCompression(lineCompression),
marginTop(marginTop), marginTop(marginTop),
@ -58,8 +139,29 @@ class ChapterHtmlSlimParser {
marginBottom(marginBottom), marginBottom(marginBottom),
marginLeft(marginLeft), marginLeft(marginLeft),
extraParagraphSpacing(extraParagraphSpacing), extraParagraphSpacing(extraParagraphSpacing),
completePageFn(completePageFn) {} cacheDir(cacheDir),
~ChapterHtmlSlimParser() = default; inlineFootnoteCount(0) {
// Initialize all footnote pointers to null
for (int i = 0; i < 16; i++) {
inlineFootnotes[i].text = nullptr;
inlineFootnotes[i].id[0] = '\0';
}
}
~ChapterHtmlSlimParser() {
// Manual cleanup of inline footnotes
for (int i = 0; i < inlineFootnoteCount; i++) {
if (inlineFootnotes[i].text) {
free(inlineFootnotes[i].text);
inlineFootnotes[i].text = nullptr;
}
}
}
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line); void addLineToPage(std::shared_ptr<TextBlock> line);
};
void setNoterefCallback(const std::function<void(Noteref&)>& callback) {
noterefCallback = callback;
}
};

View File

@ -19,7 +19,18 @@ void EpubReaderChapterSelectionScreen::onEnter() {
} }
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
selectorIndex = currentSpineIndex;
// Build filtered chapter list (excluding footnote pages)
buildFilteredChapterList();
// Find the index in filtered list that corresponds to currentSpineIndex
selectorIndex = 0;
for (size_t i = 0; i < filteredSpineIndices.size(); i++) {
if (filteredSpineIndices[i] == currentSpineIndex) {
selectorIndex = i;
break;
}
}
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -42,6 +53,30 @@ void EpubReaderChapterSelectionScreen::onExit() {
renderingMutex = nullptr; renderingMutex = nullptr;
} }
void EpubReaderChapterSelectionScreen::buildFilteredChapterList() {
filteredSpineIndices.clear();
for (int i = 0; i < epub->getSpineItemsCount(); i++) {
// Skip footnote pages
if (epub->shouldHideFromToc(i)) {
Serial.printf("[%lu] [CHAP] Hiding footnote page at spine index: %d\n", millis(), i);
continue;
}
// Skip pages without TOC entry (unnamed pages)
int tocIndex = epub->getTocIndexForSpineIndex(i);
if (tocIndex == -1) {
Serial.printf("[%lu] [CHAP] Hiding unnamed page at spine index: %d\n", millis(), i);
continue;
}
filteredSpineIndices.push_back(i);
}
Serial.printf("[%lu] [CHAP] Filtered chapters: %d out of %d\n",
millis(), filteredSpineIndices.size(), epub->getSpineItemsCount());
}
void EpubReaderChapterSelectionScreen::handleInput() { void EpubReaderChapterSelectionScreen::handleInput() {
const bool prevReleased = const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
@ -51,22 +86,25 @@ void EpubReaderChapterSelectionScreen::handleInput() {
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
onSelectSpineIndex(selectorIndex); // Get the actual spine index from filtered list
if (selectorIndex >= 0 && selectorIndex < filteredSpineIndices.size()) {
onSelectSpineIndex(filteredSpineIndices[selectorIndex]);
}
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) { } else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack(); onGoBack();
} else if (prevReleased) { } else if (prevReleased) {
if (skipPage) { if (skipPage) {
selectorIndex = selectorIndex =
((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + epub->getSpineItemsCount()) % epub->getSpineItemsCount(); ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + filteredSpineIndices.size()) % filteredSpineIndices.size();
} else { } else {
selectorIndex = (selectorIndex + epub->getSpineItemsCount() - 1) % epub->getSpineItemsCount(); selectorIndex = (selectorIndex + filteredSpineIndices.size() - 1) % filteredSpineIndices.size();
} }
updateRequired = true; updateRequired = true;
} else if (nextReleased) { } else if (nextReleased) {
if (skipPage) { if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % epub->getSpineItemsCount(); selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % filteredSpineIndices.size();
} else { } else {
selectorIndex = (selectorIndex + 1) % epub->getSpineItemsCount(); selectorIndex = (selectorIndex + 1) % filteredSpineIndices.size();
} }
updateRequired = true; updateRequired = true;
} }
@ -90,10 +128,19 @@ void EpubReaderChapterSelectionScreen::renderScreen() {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD);
if (filteredSpineIndices.empty()) {
renderer.drawCenteredText(SMALL_FONT_ID, 300, "No chapters available", true);
renderer.displayBuffer();
return;
}
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < epub->getSpineItemsCount() && i < pageStartIndex + PAGE_ITEMS; i++) {
const int tocIndex = epub->getTocIndexForSpineIndex(i); for (int i = pageStartIndex; i < filteredSpineIndices.size() && i < pageStartIndex + PAGE_ITEMS; i++) {
const int actualSpineIndex = filteredSpineIndices[i];
const int tocIndex = epub->getTocIndexForSpineIndex(actualSpineIndex);
if (tocIndex == -1) { if (tocIndex == -1) {
renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, "Unnamed", i != selectorIndex); renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, "Unnamed", i != selectorIndex);
} else { } else {
@ -104,4 +151,4 @@ void EpubReaderChapterSelectionScreen::renderScreen() {
} }
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -5,6 +5,7 @@
#include <freertos/task.h> #include <freertos/task.h>
#include <memory> #include <memory>
#include <vector>
#include "Screen.h" #include "Screen.h"
@ -18,11 +19,15 @@ class EpubReaderChapterSelectionScreen final : public Screen {
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void(int newSpineIndex)> onSelectSpineIndex; const std::function<void(int newSpineIndex)> onSelectSpineIndex;
// Filtered list of spine indices (excluding footnote pages)
std::vector<int> filteredSpineIndices;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void renderScreen(); void renderScreen();
void buildFilteredChapterList();
public: public:
explicit EpubReaderChapterSelectionScreen(GfxRenderer& renderer, InputManager& inputManager, explicit EpubReaderChapterSelectionScreen(GfxRenderer& renderer, InputManager& inputManager,
const std::shared_ptr<Epub>& epub, const int currentSpineIndex, const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack, const std::function<void()>& onGoBack,
@ -35,4 +40,4 @@ class EpubReaderChapterSelectionScreen final : public Screen {
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void handleInput() override; void handleInput() override;
}; };

View File

@ -0,0 +1,91 @@
#include "EpubReaderFootnotesScreen.h"
#include "config.h"
#include <GfxRenderer.h>
void EpubReaderFootnotesScreen::onEnter() {
selectedIndex = 0;
render();
}
void EpubReaderFootnotesScreen::onExit() {
// Nothing to clean up
}
void EpubReaderFootnotesScreen::handleInput() {
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack();
return;
}
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
const FootnoteEntry* entry = footnotes.getEntry(selectedIndex);
if (entry) {
Serial.printf("[%lu] [FNS] Selected footnote: %s -> %s\n",
millis(), entry->number, entry->href);
// Appeler le callback - EpubReaderScreen gère la navigation
onSelectFootnote(entry->href);
}
return;
}
bool needsRedraw = false;
if (inputManager.wasPressed(InputManager::BTN_UP)) {
if (selectedIndex > 0) {
selectedIndex--;
needsRedraw = true;
}
}
if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (selectedIndex < footnotes.getCount() - 1) {
selectedIndex++;
needsRedraw = true;
}
}
if (needsRedraw) {
render();
}
}
void EpubReaderFootnotesScreen::render() {
renderer.clearScreen();
constexpr int startY = 50;
constexpr int lineHeight = 40;
constexpr int marginLeft = 20;
// Title
renderer.drawText(READER_FONT_ID, marginLeft, 20, "Footnotes", BOLD);
if (footnotes.getCount() == 0) {
renderer.drawText(SMALL_FONT_ID, marginLeft, startY + 20, "No footnotes on this page");
renderer.displayBuffer();
return;
}
// Display footnotes
for (int i = 0; i < footnotes.getCount(); i++) {
const FootnoteEntry* entry = footnotes.getEntry(i);
if (!entry) continue;
const int y = startY + i * lineHeight;
// Draw selection indicator (arrow)
if (i == selectedIndex) {
renderer.drawText(READER_FONT_ID, marginLeft - 10, y, ">", BOLD);
renderer.drawText(READER_FONT_ID, marginLeft + 10, y, entry->number, BOLD);
} else {
renderer.drawText(READER_FONT_ID, marginLeft + 10, y, entry->number);
}
}
// Instructions at bottom
renderer.drawText(SMALL_FONT_ID, marginLeft,
GfxRenderer::getScreenHeight() - 40,
"UP/DOWN: Select CONFIRM: Go to footnote BACK: Return");
renderer.displayBuffer();
}

View File

@ -0,0 +1,67 @@
#pragma once
#include "Screen.h"
#include "../../lib/Epub/Epub/FootnoteEntry.h"
#include <functional>
#include <memory>
#include <cstring>
class FootnotesData {
private:
FootnoteEntry entries[32];
int count;
public:
FootnotesData() : count(0) {}
void addFootnote(const char* number, const char* href) {
if (count < 32) {
strncpy(entries[count].number, number, 2);
entries[count].number[2] = '\0';
strncpy(entries[count].href, href, 63);
entries[count].href[63] = '\0';
count++;
}
}
void clear() {
count = 0;
}
int getCount() const {
return count;
}
const FootnoteEntry* getEntry(int index) const {
if (index >= 0 && index < count) {
return &entries[index];
}
return nullptr;
}
};
class EpubReaderFootnotesScreen final : public Screen {
const FootnotesData& footnotes;
const std::function<void()> onGoBack;
const std::function<void(const char*)> onSelectFootnote;
int selectedIndex;
public:
EpubReaderFootnotesScreen(
GfxRenderer& renderer,
InputManager& inputManager,
const FootnotesData& footnotes,
const std::function<void()>& onGoBack,
const std::function<void(const char*)>& onSelectFootnote)
: Screen(renderer, inputManager),
footnotes(footnotes),
onGoBack(onGoBack),
onSelectFootnote(onSelectFootnote),
selectedIndex(0) {}
void onEnter() override;
void onExit() override;
void handleInput() override;
private:
void render();
};

View File

@ -0,0 +1,98 @@
//
// Created by jlaunay on 13/12/2025.
//
#include "EpubReaderMenuScreen.h"
#include <GfxRenderer.h>
#include "config.h"
constexpr int MENU_ITEMS_COUNT = 2;
void EpubReaderMenuScreen::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderMenuScreen*>(param);
self->displayTaskLoop();
}
void EpubReaderMenuScreen::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
selectorIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderMenuScreen::taskTrampoline, "EpubReaderMenuTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void EpubReaderMenuScreen::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void EpubReaderMenuScreen::handleInput() {
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
onSelectOption(static_cast<MenuOption>(selectorIndex));
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack();
} else if (prevReleased) {
selectorIndex = (selectorIndex + MENU_ITEMS_COUNT - 1) % MENU_ITEMS_COUNT;
updateRequired = true;
} else if (nextReleased) {
selectorIndex = (selectorIndex + 1) % MENU_ITEMS_COUNT;
updateRequired = true;
}
}
void EpubReaderMenuScreen::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderMenuScreen::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "Menu", true, BOLD);
const char* menuItems[MENU_ITEMS_COUNT] = {
"Go to chapter",
"View footnotes"
};
const int startY = 100;
const int itemHeight = 40;
for (int i = 0; i < MENU_ITEMS_COUNT; i++) {
const int y = startY + i * itemHeight;
// Draw selection indicator
if (i == selectorIndex) {
renderer.fillRect(10, y + 2, pageWidth - 20, itemHeight - 4);
renderer.drawText(UI_FONT_ID, 30, y, menuItems[i], false);
} else {
renderer.drawText(UI_FONT_ID, 30, y, menuItems[i], true);
}
}
renderer.displayBuffer();
}

View File

@ -0,0 +1,45 @@
//
// Created by jlaunay on 13/12/2025.
//
#ifndef CROSSPOINT_READER_EPUBREADERMENUSCREEN_H
#define CROSSPOINT_READER_EPUBREADERMENUSCREEN_H
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "Screen.h"
class EpubReaderMenuScreen final : public Screen {
public:
enum MenuOption {
CHAPTERS,
FOOTNOTES
};
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void(MenuOption option)> onSelectOption;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
public:
explicit EpubReaderMenuScreen(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void()>& onGoBack,
const std::function<void(MenuOption option)>& onSelectOption)
: Screen(renderer, inputManager),
onGoBack(onGoBack),
onSelectOption(onSelectOption) {}
void onEnter() override;
void onExit() override;
void handleInput() override;
};
#endif // CROSSPOINT_READER_EPUBREADERMENUSCREEN_H

View File

@ -1,5 +1,6 @@
#include "EpubReaderScreen.h" #include "EpubReaderScreen.h"
#include "EpubReaderFootnotesScreen.h"
#include <Epub/Page.h> #include <Epub/Page.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SD.h> #include <SD.h>
@ -7,6 +8,7 @@
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "EpubReaderChapterSelectionScreen.h" #include "EpubReaderChapterSelectionScreen.h"
#include "EpubReaderMenuScreen.h"
#include "config.h" #include "config.h"
constexpr int PAGES_PER_REFRESH = 15; constexpr int PAGES_PER_REFRESH = 15;
@ -28,7 +30,6 @@ void EpubReaderScreen::onEnter() {
} }
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
epub->setupCacheDir(); epub->setupCacheDir();
if (SD.exists((epub->getCachePath() + "/progress.bin").c_str())) { if (SD.exists((epub->getCachePath() + "/progress.bin").c_str())) {
@ -45,10 +46,10 @@ void EpubReaderScreen::onEnter() {
updateRequired = true; updateRequired = true;
xTaskCreate(&EpubReaderScreen::taskTrampoline, "EpubReaderScreenTask", xTaskCreate(&EpubReaderScreen::taskTrampoline, "EpubReaderScreenTask",
8192, // Stack size 24576, //32768
this, // Parameters this,
1, // Priority 1,
&displayTaskHandle // Task handle &displayTaskHandle
); );
} }
@ -72,27 +73,79 @@ void EpubReaderScreen::handleInput() {
return; return;
} }
// Enter chapter selection screen // Enter Menu selection screen
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (isViewingFootnote) {
restoreSavedPosition();
updateRequired = true;
return;
} else {
onGoBack();
return;
}
}
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
// Don't start screen transition while rendering // Don't start screen transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
subScreen.reset(new EpubReaderChapterSelectionScreen(
this->renderer, this->inputManager, epub, currentSpineIndex, subScreen.reset(new EpubReaderMenuScreen(
this->renderer, this->inputManager,
[this] { [this] {
// onGoBack - return to reading
subScreen->onExit(); subScreen->onExit();
subScreen.reset(); subScreen.reset();
updateRequired = true; updateRequired = true;
}, },
[this](const int newSpineIndex) { [this](EpubReaderMenuScreen::MenuOption option) {
if (currentSpineIndex != newSpineIndex) { // onSelectOption - handle menu choice
currentSpineIndex = newSpineIndex; if (option == EpubReaderMenuScreen::CHAPTERS) {
nextPageNumber = 0; // Show chapter selection
section.reset(); subScreen->onExit();
subScreen.reset(new EpubReaderChapterSelectionScreen(
this->renderer, this->inputManager, epub, currentSpineIndex,
[this] {
// onGoBack from chapter selection
subScreen->onExit();
subScreen.reset();
updateRequired = true;
},
[this](const int newSpineIndex) {
// onSelectSpineIndex
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
subScreen->onExit();
subScreen.reset();
updateRequired = true;
}));
subScreen->onEnter();
} else if (option == EpubReaderMenuScreen::FOOTNOTES) {
// Show footnotes page with current page notes
subScreen->onExit();
subScreen.reset(new EpubReaderFootnotesScreen(
this->renderer,
this->inputManager,
currentPageFootnotes, // Pass collected footnotes (reference)
[this] {
// onGoBack from footnotes
subScreen->onExit();
subScreen.reset();
updateRequired = true;
},
[this](const char* href) {
// onSelectFootnote - navigate to the footnote location
navigateToHref(href, true); // true = save current position
subScreen->onExit();
subScreen.reset();
updateRequired = true;
}));
subScreen->onEnter();
} }
subScreen->onExit();
subScreen.reset();
updateRequired = true;
})); }));
subScreen->onEnter(); subScreen->onEnter();
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
} }
@ -111,7 +164,7 @@ void EpubReaderScreen::handleInput() {
return; return;
} }
// any botton press when at end of the book goes back to the last page // any button press when at end of the book goes back to the last page
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
currentSpineIndex = epub->getSpineItemsCount() - 1; currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = UINT16_MAX; nextPageNumber = UINT16_MAX;
@ -177,17 +230,16 @@ void EpubReaderScreen::displayTaskLoop() {
} }
} }
// TODO: Failure handling
void EpubReaderScreen::renderScreen() { void EpubReaderScreen::renderScreen() {
if (!epub) { if (!epub) {
return; return;
} }
// edge case handling for sub-zero spine index // Edge case handling for sub-zero spine index
if (currentSpineIndex < 0) { if (currentSpineIndex < 0) {
currentSpineIndex = 0; currentSpineIndex = 0;
} }
// based bounds of book, show end of book screen // Based bounds of book, show end of book screen
if (currentSpineIndex > epub->getSpineItemsCount()) { if (currentSpineIndex > epub->getSpineItemsCount()) {
currentSpineIndex = epub->getSpineItemsCount(); currentSpineIndex = epub->getSpineItemsCount();
} }
@ -262,27 +314,40 @@ void EpubReaderScreen::renderScreen() {
return; return;
} }
{ // Load page from SD - use pointer to avoid copying on stack
auto p = section->loadPageFromSD(); std::unique_ptr<Page> p = section->loadPageFromSD();
if (!p) { if (!p) {
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis()); Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
section->clearCache(); section->clearCache();
section.reset(); section.reset();
return renderScreen(); return renderScreen();
}
const auto start = millis();
renderContents(std::move(p));
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
} }
// Copy footnotes from page to currentPageFootnotes
currentPageFootnotes.clear();
for (int i = 0; i < p->footnoteCount && i < 16; i++) {
FootnoteEntry* footnote = p->getFootnote(i);
if (footnote) {
currentPageFootnotes.addFootnote(footnote->number, footnote->href);
}
}
Serial.printf("[%lu] [ERS] Loaded %d footnotes for current page\n", millis(), p->footnoteCount);
const auto start = millis();
renderContents(std::move(p));
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
// Save progress
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE); File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE);
uint8_t data[4]; if (f) {
data[0] = currentSpineIndex & 0xFF; uint8_t data[4];
data[1] = (currentSpineIndex >> 8) & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[2] = section->currentPage & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF; data[2] = section->currentPage & 0xFF;
f.write(data, 4); data[3] = (section->currentPage >> 8) & 0xFF;
f.close(); f.write(data, 4);
f.close();
}
} }
void EpubReaderScreen::renderContents(std::unique_ptr<Page> page) { void EpubReaderScreen::renderContents(std::unique_ptr<Page> page) {
@ -325,18 +390,19 @@ void EpubReaderScreen::renderContents(std::unique_ptr<Page> page) {
void EpubReaderScreen::renderStatusBar() const { void EpubReaderScreen::renderStatusBar() const {
constexpr auto textY = 776; constexpr auto textY = 776;
// Right aligned text for progress counter // Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + " / " + std::to_string(section->pageCount); char progressBuf[32]; // Use fixed buffer instead of std::string
const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); snprintf(progressBuf, sizeof(progressBuf), "%d / %d", section->currentPage + 1, section->pageCount);
renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressBuf);
progress.c_str()); renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, progressBuf);
// Left aligned battery icon and percentage // Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage(); const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%"; char percentageBuf[8]; // Use fixed buffer instead of std::string
const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); snprintf(percentageBuf, sizeof(percentageBuf), "%d%%", percentage);
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageBuf);
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageBuf);
// 1 column on left, 2 columns on right, 5 columns of battery body // Battery icon drawing
constexpr int batteryWidth = 15; constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10; constexpr int batteryHeight = 10;
constexpr int x = marginLeft; constexpr int x = marginLeft;
@ -354,34 +420,160 @@ void EpubReaderScreen::renderStatusBar() const {
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel // Fill battery based on percentage
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) { if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow filledWidth = batteryWidth - 5;
} }
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
// Centered chatper title text // Centered chapter title text
// Page width minus existing content with 30px padding on each side
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft;
const int titleMarginRight = progressTextWidth + 30 + marginRight; const int titleMarginRight = progressTextWidth + 30 + marginRight;
const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight;
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string title;
int titleWidth;
if (tocIndex == -1) { if (tocIndex == -1) {
title = "Unnamed"; const char* title = "Unnamed";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); const int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title);
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title);
} else { } else {
const auto tocItem = epub->getTocItem(tocIndex); const auto& tocItem = epub->getTocItem(tocIndex);
title = tocItem.title; std::string title = tocItem.title;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth) {
// Truncate title if too long
while (titleWidth > availableTextWidth && title.length() > 8) {
title = title.substr(0, title.length() - 8) + "..."; title = title.substr(0, title.length() - 8) + "...";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
}
}
void EpubReaderScreen::navigateToHref(const char* href, bool savePosition) {
if (!epub || !href) return;
// Save current position if requested
if (savePosition && section) {
savedSpineIndex = currentSpineIndex;
savedPageNumber = section->currentPage;
isViewingFootnote = true;
Serial.printf("[%lu] [ERS] Saved position: spine %d, page %d\n",
millis(), savedSpineIndex, savedPageNumber);
} }
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); // Parse href: "filename.html#anchor"
std::string hrefStr(href);
std::string filename;
std::string anchor;
size_t hashPos = hrefStr.find('#');
if (hashPos != std::string::npos) {
filename = hrefStr.substr(0, hashPos);
anchor = hrefStr.substr(hashPos + 1);
} else {
filename = hrefStr;
}
// Extract just filename without path
size_t lastSlash = filename.find_last_of('/');
if (lastSlash != std::string::npos) {
filename = filename.substr(lastSlash + 1);
}
Serial.printf("[%lu] [ERS] Navigate to: %s (anchor: %s)\n",
millis(), filename.c_str(), anchor.c_str());
int targetSpineIndex = -1;
// FIRST: Check if we have an inline footnote or paragraph note for this anchor
if (!anchor.empty()) {
// Try inline footnote first
std::string inlineFilename = "inline_" + anchor + ".html";
Serial.printf("[%lu] [ERS] Looking for inline footnote: %s\n",
millis(), inlineFilename.c_str());
targetSpineIndex = epub->findVirtualSpineIndex(inlineFilename);
// If not found, try paragraph note
if (targetSpineIndex == -1) {
std::string pnoteFilename = "pnote_" + anchor + ".html";
Serial.printf("[%lu] [ERS] Looking for paragraph note: %s\n",
millis(), pnoteFilename.c_str());
targetSpineIndex = epub->findVirtualSpineIndex(pnoteFilename);
}
if (targetSpineIndex != -1) {
Serial.printf("[%lu] [ERS] Found note at virtual index: %d\n",
millis(), targetSpineIndex);
// Navigate to the note
xSemaphoreTake(renderingMutex, portMAX_DELAY);
currentSpineIndex = targetSpineIndex;
nextPageNumber = 0;
section.reset();
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
} else {
Serial.printf("[%lu] [ERS] No virtual note found, trying normal navigation\n",
millis());
}
}
// FALLBACK: Try to find the file in normal spine items
for (int i = 0; i < epub->getSpineItemsCount(); i++) {
if (epub->isVirtualSpineItem(i)) continue;
std::string spineItem = epub->getSpineItem(i);
size_t lastSlash = spineItem.find_last_of('/');
std::string spineFilename = (lastSlash != std::string::npos)
? spineItem.substr(lastSlash + 1)
: spineItem;
if (spineFilename == filename) {
targetSpineIndex = i;
break;
}
}
if (targetSpineIndex == -1) {
Serial.printf("[%lu] [ERS] Could not find spine index for: %s\n",
millis(), filename.c_str());
return;
}
// Navigate to the target chapter
xSemaphoreTake(renderingMutex, portMAX_DELAY);
currentSpineIndex = targetSpineIndex;
nextPageNumber = 0;
section.reset();
xSemaphoreGive(renderingMutex);
updateRequired = true;
Serial.printf("[%lu] [ERS] Navigated to spine index: %d\n",
millis(), targetSpineIndex);
}
// Method to restore saved position
void EpubReaderScreen::restoreSavedPosition() {
if (savedSpineIndex >= 0 && savedPageNumber >= 0) {
Serial.printf("[%lu] [ERS] Restoring position: spine %d, page %d\n",
millis(), savedSpineIndex, savedPageNumber);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
currentSpineIndex = savedSpineIndex;
nextPageNumber = savedPageNumber;
section.reset();
xSemaphoreGive(renderingMutex);
savedSpineIndex = -1;
savedPageNumber = -1;
isViewingFootnote = false;
}
} }

View File

@ -4,6 +4,7 @@
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include "EpubReaderFootnotesScreen.h"
#include "Screen.h" #include "Screen.h"
@ -18,6 +19,11 @@ class EpubReaderScreen final : public Screen {
int pagesUntilFullRefresh = 0; int pagesUntilFullRefresh = 0;
bool updateRequired = false; bool updateRequired = false;
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
FootnotesData currentPageFootnotes;
int savedSpineIndex = -1;
int savedPageNumber = -1;
bool isViewingFootnote = false;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
@ -25,7 +31,11 @@ class EpubReaderScreen final : public Screen {
void renderContents(std::unique_ptr<Page> p); void renderContents(std::unique_ptr<Page> p);
void renderStatusBar() const; void renderStatusBar() const;
public: // Footnote navigation methods
void navigateToHref(const char* href, bool savePosition = false);
void restoreSavedPosition();
public:
explicit EpubReaderScreen(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub, explicit EpubReaderScreen(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack) const std::function<void()>& onGoBack)
: Screen(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} : Screen(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {}