From 112624d096abcfc6e0a901ae141c210bb8d8bf2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Launay?= Date: Wed, 17 Dec 2025 02:06:38 +0100 Subject: [PATCH] footnotes support --- lib/Epub/Epub.cpp | 122 ++++- lib/Epub/Epub.h | 39 +- lib/Epub/Epub/FootnoteEntry.h | 12 + lib/Epub/Epub/Page.cpp | 43 +- lib/Epub/Epub/Page.h | 75 ++- lib/Epub/Epub/Section.cpp | 196 +++++++- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 437 ++++++++++++++++-- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 85 +++- .../EpubReaderChapterSelectionScreen.cpp | 65 ++- .../EpubReaderChapterSelectionScreen.h | 9 +- src/screens/EpubReaderFootnotesScreen.cpp | 91 ++++ src/screens/EpubReaderFootnotesScreen.h | 67 +++ src/screens/EpubReaderMenuScreen.cpp | 98 ++++ src/screens/EpubReaderMenuScreen.h | 45 ++ src/screens/EpubReaderScreen.cpp | 300 +++++++++--- src/screens/EpubReaderScreen.h | 14 +- 16 files changed, 1531 insertions(+), 167 deletions(-) create mode 100644 lib/Epub/Epub/FootnoteEntry.h create mode 100644 src/screens/EpubReaderFootnotesScreen.cpp create mode 100644 src/screens/EpubReaderFootnotesScreen.h create mode 100644 src/screens/EpubReaderMenuScreen.cpp create mode 100644 src/screens/EpubReaderMenuScreen.h diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 8b4bb9a..5615260 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -130,6 +130,10 @@ bool Epub::load() { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); ZipFile zip("/sd" + filepath); + if (!footnotePages) { + footnotePages = new std::unordered_set(); + } + std::string contentOpfFilePath; if (!findContentOpfFile(&contentOpfFilePath)) { Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); @@ -255,15 +259,32 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { 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) { - if (spineIndex < 0 || spineIndex >= spine.size()) { - Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); - return spine.at(0).second; +std::string Epub::getSpineItem(const int spineIndex) const { + if (spineIndex < 0) { + Serial.printf("[%lu] [EBP] getSpineItem index:%d is negative\n", millis(), spineIndex); + return ""; } - return spine.at(spineIndex).second; + // Normal spine item + if (spineIndex < static_cast(spine.size())) { + return contentBasePath + spine.at(spineIndex).second; + } + + // Virtual spine item + if (virtualSpineItems) { + int virtualIndex = spineIndex - spine.size(); + if (virtualIndex >= 0 && virtualIndex < static_cast(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) { @@ -293,6 +314,11 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) 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 // so we can find the toc index by looking for the href for (int i = 0; i < toc.size(); i++) { @@ -304,3 +330,87 @@ int Epub::getTocIndexForSpineIndex(const int spineIndex) const { Serial.printf("[%lu] [EBP] TOC item not found\n", millis()); return -1; } + +void Epub::markAsFootnotePage(const std::string& href) { + // Lazy initialization + if (!footnotePages) { + footnotePages = new std::unordered_set(); + } + + // 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(); + } + + 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(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; +} \ No newline at end of file diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 765eacc..46b9a29 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "Epub/EpubTocEntry.h" @@ -10,33 +11,36 @@ class ZipFile; class Epub { - // the title read from the EPUB meta data std::string title; - // the cover image std::string coverImageItem; - // the ncx file std::string tocNcxItem; - // where is the EPUBfile? std::string filepath; - // the spine of the EPUB file std::vector> spine; - // the toc of the EPUB file std::vector toc; - // the base path for items in the EPUB file std::string contentBasePath; - // Uniq cache key based on filepath std::string cachePath; + // Use pointers, allocate only if needed + std::unordered_set* footnotePages; + std::vector* virtualSpineItems; + bool findContentOpfFile(std::string* contentOpfFile) const; bool parseContentOpf(const std::string& contentOpfFilePath); bool parseTocNcxFile(); public: - explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { - // create a cache key based on the filepath + explicit Epub(std::string filepath, const std::string& cacheDir) + : filepath(std::move(filepath)), + footnotePages(nullptr), + virtualSpineItems(nullptr) { cachePath = cacheDir + "/epub_" + std::to_string(std::hash{}(this->filepath)); } - ~Epub() = default; + + ~Epub() { + delete footnotePages; + delete virtualSpineItems; + } + std::string& getBasePath() { return contentBasePath; } bool load(); bool clearCache() const; @@ -49,10 +53,19 @@ class Epub { bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) 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; + EpubTocEntry& getTocItem(int tocTndex); int getTocItemsCount() const; int getSpineIndexForTocIndex(int tocIndex) 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; +}; \ No newline at end of file diff --git a/lib/Epub/Epub/FootnoteEntry.h b/lib/Epub/Epub/FootnoteEntry.h new file mode 100644 index 0000000..39f0c26 --- /dev/null +++ b/lib/Epub/Epub/FootnoteEntry.h @@ -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'; + } +}; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 09abd08..946c3fe 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -1,17 +1,16 @@ #include "Page.h" - #include #include -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) { serialization::writePod(os, xPos); serialization::writePod(os, yPos); - - // serialize TextBlock pointed to by PageLine block->serialize(os); } @@ -26,21 +25,24 @@ std::unique_ptr PageLine::deserialize(std::istream& is) { } void Page::render(GfxRenderer& renderer, const int fontId) const { - for (auto& element : elements) { - element->render(renderer, fontId); + for (int i = 0; i < elementCount; i++) { + elements[i]->render(renderer, fontId); } } void Page::serialize(std::ostream& os) const { serialization::writePod(os, PAGE_FILE_VERSION); + serialization::writePod(os, static_cast(elementCount)); - const uint32_t count = elements.size(); - serialization::writePod(os, count); - - for (const auto& el : elements) { - // Only PageLine exists currently + for (int i = 0; i < elementCount; i++) { serialization::writePod(os, static_cast(TAG_PageLine)); - el->serialize(os); + elements[i]->serialize(os); + } + + serialization::writePod(os, static_cast(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::deserialize(std::istream& is) { uint32_t 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; serialization::readPod(is, tag); if (tag == TAG_PageLine) { auto pl = PageLine::deserialize(is); - page->elements.push_back(std::move(pl)); + page->addElement(std::move(pl)); } else { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); 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; -} +} \ No newline at end of file diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 59333ce..ae00505 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -1,14 +1,16 @@ #pragma once +#include +#include #include -#include +#include +#include "FootnoteEntry.h" #include "blocks/TextBlock.h" enum PageElementTag : uint8_t { TAG_PageLine = 1, }; -// represents something that has been added to a page class PageElement { public: int16_t xPos; @@ -19,11 +21,10 @@ class PageElement { virtual void serialize(std::ostream& os) = 0; }; -// a line from a block element class PageLine final : public PageElement { std::shared_ptr block; - public: +public: PageLine(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), block(std::move(block)) {} void render(GfxRenderer& renderer, int fontId) override; @@ -32,10 +33,68 @@ class PageLine final : public PageElement { }; class Page { - public: - // the list of block index and line numbers on this page - std::vector> elements; +private: + std::shared_ptr* elements; + int elementCapacity; + + FootnoteEntry* footnotes; + int footnoteCapacity; + +public: + int elementCount; + int footnoteCount; + + Page() : elementCount(0), footnoteCount(0) { + elementCapacity = 24; + elements = new std::shared_ptr[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 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 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 serialize(std::ostream& os) const; static std::unique_ptr deserialize(std::istream& is); -}; +}; \ No newline at end of file diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 9c05208..55357d1 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -4,12 +4,68 @@ #include #include +#include #include "FsHelpers.h" #include "Page.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], "<", 4); + bufferPos += 4; + } + } else if (c == '>') { + if (bufferPos + 4 < sizeof(buffer)) { + memcpy(&buffer[bufferPos], ">", 4); + bufferPos += 4; + } + } else if (c == '&') { + if (bufferPos + 5 < sizeof(buffer)) { + memcpy(&buffer[bufferPos], "&", 5); + bufferPos += 5; + } + } else if (c == '"') { + if (bufferPos + 6 < sizeof(buffer)) { + memcpy(&buffer[bufferPos], """, 6); + bufferPos += 6; + } + } else if (c == '\'') { + if (bufferPos + 6 < sizeof(buffer)) { + memcpy(&buffer[bufferPos], "'", 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) { const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; @@ -96,7 +152,6 @@ void Section::setupCacheDir() const { 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 { if (!SD.exists(cachePath.c_str())) { 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 auto localPath = epub->getSpineItem(spineIndex); - // TODO: Should we get rid of this file all together? - // It currently saves us a bit of memory by allowing for all the inflation bits to be released - // before loading the XML parser + // Check if it's a virtual spine item + if (epub->isVirtualSpineItem(spineIndex)) { + 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) { 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"; File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true); bool success = epub->readItemContentsToStream(localPath, f, 1024); @@ -136,15 +211,122 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing, - [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }); + [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }, + cachePath); + + // Track which inline footnotes are actually referenced in this file + std::set rewrittenInlineIds; + int noterefCount = 0; + + visitor.setNoterefCallback([this, ¬erefCount, &rewrittenInlineIds](Noteref& noteref) { + Serial.printf("[%lu] [SCT] Callback noteref: %s -> %s\n", millis(), noteref.number, noteref.href); + + // Check if this was rewritten to an inline footnote + std::string href(noteref.href); + if (href.find("inline_") == 0) { + // Extract ID from "inline_N3.html#N3" + size_t underscorePos = href.find('_'); + size_t dotPos = href.find('.'); + + if (underscorePos != std::string::npos && dotPos != std::string::npos) { + std::string inlineId = href.substr(underscorePos + 1, dotPos - underscorePos - 1); + rewrittenInlineIds.insert(inlineId); + Serial.printf("[%lu] [SCT] Marked inline footnote as rewritten: %s\n", + millis(), inlineId.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(); SD.remove(tmpHtmlPath.c_str()); + if (!success) { Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis()); 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(""); + file.println(""); + file.println(""); + file.println(""); + file.println(""); + file.println("Footnote"); + file.println(""); + file.println(""); + + // Paragraph with content + file.print("

"); + + if (!writeEscapedXml(file, inlineText)) { + Serial.printf("[%lu] [SCT] Warning: writeEscapedXml may have failed\n", millis()); + } + + file.println("

"); + file.println(""); + file.println(""); + file.close(); + + Serial.printf("[%lu] [SCT] Generated inline footnote file\n", millis()); + + // Add as virtual spine item (full path for epub to find it) + 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()); + } + } + + Serial.printf("[%lu] [SCT] Total noterefs found: %d\n", millis(), noterefCount); + writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing); return true; @@ -161,4 +343,4 @@ std::unique_ptr Section::loadPageFromSD() const { auto page = Page::deserialize(inputFile); inputFile.close(); return page; -} +} \ No newline at end of file diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index ea15e1a..4580a3b 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #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'; } -// 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) { for (int i = 0; i < possible_tag_count; i++) { if (strcmp(tag_name, possible_tags[i]) == 0) { @@ -37,23 +37,139 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib 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) { if (currentTextBlock) { - // already have a text block running and it is empty - just reuse it if (currentTextBlock->isEmpty()) { currentTextBlock->setStyle(style); return; } - makePages(); } 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; + } + } + + 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) { auto* self = static_cast(userData); - (void)atts; + + 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: Find the aside text and output it as normal content + Serial.printf("[%lu] [ASIDE] Rendering aside as content 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; + } + } + + // During pass 1, we ONLY collect asides, skip everything else + if (self->isPass1CollectingAsides) { + self->depth += 1; + return; + } + + // Pass 2: Normal parsing, but skip asides (we already have them) + if (self->insideAsideFootnote) { + self->depth += 1; + return; + } // Middle of skip if (self->skipUntilDepth < self->depth) { @@ -61,15 +177,40 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* 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"); + + if (epubType && strcmp(epubType, "noteref") == 0) { + Serial.printf("[%lu] [NOTEREF] Found noteref: href=%s\n", millis(), href ? href : "null"); + 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)) { - // TODO: Start processing image tags self->skipUntilDepth = self->depth; self->depth += 1; return; } if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { - // start skip self->skipUntilDepth = self->depth; self->depth += 1; return; @@ -96,7 +237,53 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char* s, const int len) { auto* self = static_cast(userData); - // Middle of skip + // 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++) { + if (!isWhitespace(s[i]) && self->currentNoterefTextLen < 15) { + self->currentNoterefText[self->currentNoterefTextLen++] = s[i]; + self->currentNoterefText[self->currentNoterefTextLen] = '\0'; + } + } + return; + } + if (self->skipUntilDepth < self->depth) { return; } @@ -112,17 +299,14 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char for (int i = 0; i < len; i++) { if (isWhitespace(s[i])) { - // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it if (self->partWordBufferIndex > 0) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); self->partWordBufferIndex = 0; } - // Skip the whitespace char 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) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); @@ -135,13 +319,88 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { auto* self = static_cast(userData); - (void)name; + + // 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(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 - UNCHANGED + 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); + } + } + + self->currentNoterefTextLen = 0; + self->currentNoterefText[0] = '\0'; + self->currentNoterefHrefLen = 0; + self->currentNoterefHref[0] = '\0'; + self->depth -= 1; + return; + } 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 . - // Currently this also flushes out on closing and tags, but they are line tags so that shouldn't happen, - // text styling needs to be overhauled to fix it. const bool shouldBreakText = 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; @@ -164,49 +423,55 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n self->depth -= 1; - // Leaving skip if (self->skipUntilDepth == self->depth) { self->skipUntilDepth = INT_MAX; } - // Leaving bold if (self->boldUntilDepth == self->depth) { self->boldUntilDepth = INT_MAX; } - // Leaving italic if (self->italicUntilDepth == self->depth) { self->italicUntilDepth = INT_MAX; } } 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); - int done; + // Reset state for pass 1 + depth = 0; + skipUntilDepth = INT_MAX; + insideAsideFootnote = false; + inlineFootnoteCount = 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()); return false; } - XML_SetUserData(parser, this); - XML_SetElementHandler(parser, startElement, endElement); - XML_SetCharacterDataHandler(parser, characterData); + XML_SetUserData(parser1, this); + XML_SetElementHandler(parser1, startElement, endElement); + XML_SetCharacterDataHandler(parser1, characterData); FILE* file = fopen(filepath, "r"); if (!file) { Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath); - XML_ParserFree(parser); + XML_ParserFree(parser1); return false; } + int done; do { - void* const buf = XML_GetBuffer(parser, 1024); + void* const buf = XML_GetBuffer(parser1, 1024); if (!buf) { Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis()); - XML_ParserFree(parser); + XML_ParserFree(parser1); fclose(file); return false; } @@ -215,29 +480,113 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { if (ferror(file)) { Serial.printf("[%lu] [EHP] File read error\n", millis()); - XML_ParserFree(parser); + XML_ParserFree(parser1); fclose(file); return false; } done = feof(file); - if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { - Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), - XML_ErrorString(XML_GetErrorCode(parser))); - XML_ParserFree(parser); + if (XML_ParseBuffer(parser1, static_cast(len), done) == XML_STATUS_ERROR) { + Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), + XML_GetCurrentLineNumber(parser1), + XML_ErrorString(XML_GetErrorCode(parser1))); + XML_ParserFree(parser1); fclose(file); return false; } } 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(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); // Process last page if there is still text if (currentTextBlock) { 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(); currentTextBlock.reset(); } @@ -250,13 +599,24 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line) { const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom; 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)); currentPage.reset(new Page()); currentPageNextY = marginTop; } - currentPage->elements.push_back(std::make_shared(line, marginLeft, currentPageNextY)); - currentPageNextY += lineHeight; + if (currentPage && currentPage->elementCount < 24) { + currentPage->addElement(std::make_shared(line, marginLeft, currentPageNextY)); + currentPageNextY += lineHeight; + } else { + Serial.printf("[%lu] [EHP] WARNING: Page element capacity reached, skipping element\n", millis()); + } } void ChapterHtmlSlimParser::makePages() { @@ -279,3 +639,4 @@ void ChapterHtmlSlimParser::makePages() { currentPageNextY += lineHeight / 2; } } + diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index f656b4a..40756d8 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -1,19 +1,35 @@ #pragma once #include - #include +#include #include #include #include "../ParsedText.h" #include "../blocks/TextBlock.h" +#include "../FootnoteEntry.h" class Page; class GfxRenderer; #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'; + } +}; + class ChapterHtmlSlimParser { const char* filepath; GfxRenderer& renderer; @@ -22,8 +38,6 @@ class ChapterHtmlSlimParser { int skipUntilDepth = INT_MAX; int boldUntilDepth = 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] = {}; int partWordBufferIndex = 0; std::unique_ptr currentTextBlock = nullptr; @@ -37,20 +51,56 @@ class ChapterHtmlSlimParser { int marginLeft; bool extraParagraphSpacing; + // Noteref tracking + bool insideNoteref = false; + char currentNoterefText[16] = {0}; + int currentNoterefTextLen = 0; + char currentNoterefHref[128] = {0}; + int currentNoterefHrefLen = 0; + std::function 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}; + + // 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 makePages(); + // XML callbacks 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 endElement(void* userData, const XML_Char* name); public: + // PUBLIC ACCESS to inline footnotes (needed by Section.cpp) + InlineFootnote inlineFootnotes[16]; + int inlineFootnoteCount = 0; + explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, - const std::function)>& completePageFn) + const std::function)>& completePageFn, + const std::string& cacheDir = "") : filepath(filepath), renderer(renderer), + completePageFn(completePageFn), fontId(fontId), lineCompression(lineCompression), marginTop(marginTop), @@ -58,8 +108,29 @@ class ChapterHtmlSlimParser { marginBottom(marginBottom), marginLeft(marginLeft), extraParagraphSpacing(extraParagraphSpacing), - completePageFn(completePageFn) {} - ~ChapterHtmlSlimParser() = default; + cacheDir(cacheDir), + 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(); void addLineToPage(std::shared_ptr line); -}; + + void setNoterefCallback(const std::function& callback) { + noterefCallback = callback; + } +}; \ No newline at end of file diff --git a/src/screens/EpubReaderChapterSelectionScreen.cpp b/src/screens/EpubReaderChapterSelectionScreen.cpp index 26688a4..e5df1ca 100644 --- a/src/screens/EpubReaderChapterSelectionScreen.cpp +++ b/src/screens/EpubReaderChapterSelectionScreen.cpp @@ -19,7 +19,18 @@ void EpubReaderChapterSelectionScreen::onEnter() { } 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 updateRequired = true; @@ -42,6 +53,30 @@ void EpubReaderChapterSelectionScreen::onExit() { 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() { const bool prevReleased = 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; 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)) { onGoBack(); } else if (prevReleased) { if (skipPage) { selectorIndex = - ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + epub->getSpineItemsCount()) % epub->getSpineItemsCount(); + ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + filteredSpineIndices.size()) % filteredSpineIndices.size(); } else { - selectorIndex = (selectorIndex + epub->getSpineItemsCount() - 1) % epub->getSpineItemsCount(); + selectorIndex = (selectorIndex + filteredSpineIndices.size() - 1) % filteredSpineIndices.size(); } updateRequired = true; } else if (nextReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % epub->getSpineItemsCount(); + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % filteredSpineIndices.size(); } else { - selectorIndex = (selectorIndex + 1) % epub->getSpineItemsCount(); + selectorIndex = (selectorIndex + 1) % filteredSpineIndices.size(); } updateRequired = true; } @@ -90,10 +128,19 @@ void EpubReaderChapterSelectionScreen::renderScreen() { const auto pageWidth = renderer.getScreenWidth(); 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; 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) { renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, "Unnamed", i != selectorIndex); } else { @@ -104,4 +151,4 @@ void EpubReaderChapterSelectionScreen::renderScreen() { } renderer.displayBuffer(); -} +} \ No newline at end of file diff --git a/src/screens/EpubReaderChapterSelectionScreen.h b/src/screens/EpubReaderChapterSelectionScreen.h index 8cac4cb..2a9291a 100644 --- a/src/screens/EpubReaderChapterSelectionScreen.h +++ b/src/screens/EpubReaderChapterSelectionScreen.h @@ -5,6 +5,7 @@ #include #include +#include #include "Screen.h" @@ -18,11 +19,15 @@ class EpubReaderChapterSelectionScreen final : public Screen { const std::function onGoBack; const std::function onSelectSpineIndex; + // Filtered list of spine indices (excluding footnote pages) + std::vector filteredSpineIndices; + static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void renderScreen(); + void buildFilteredChapterList(); - public: +public: explicit EpubReaderChapterSelectionScreen(GfxRenderer& renderer, InputManager& inputManager, const std::shared_ptr& epub, const int currentSpineIndex, const std::function& onGoBack, @@ -35,4 +40,4 @@ class EpubReaderChapterSelectionScreen final : public Screen { void onEnter() override; void onExit() override; void handleInput() override; -}; +}; \ No newline at end of file diff --git a/src/screens/EpubReaderFootnotesScreen.cpp b/src/screens/EpubReaderFootnotesScreen.cpp new file mode 100644 index 0000000..725c948 --- /dev/null +++ b/src/screens/EpubReaderFootnotesScreen.cpp @@ -0,0 +1,91 @@ +#include "EpubReaderFootnotesScreen.h" +#include "config.h" +#include + +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, "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(); +} \ No newline at end of file diff --git a/src/screens/EpubReaderFootnotesScreen.h b/src/screens/EpubReaderFootnotesScreen.h new file mode 100644 index 0000000..6c2e021 --- /dev/null +++ b/src/screens/EpubReaderFootnotesScreen.h @@ -0,0 +1,67 @@ +#pragma once +#include "Screen.h" +#include "../../lib/Epub/Epub/FootnoteEntry.h" +#include +#include +#include + +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 onGoBack; + const std::function onSelectFootnote; + int selectedIndex; + +public: + EpubReaderFootnotesScreen( + GfxRenderer& renderer, + InputManager& inputManager, + const FootnotesData& footnotes, + const std::function& onGoBack, + const std::function& onSelectFootnote) + : Screen(renderer, inputManager), + footnotes(footnotes), + onGoBack(onGoBack), + onSelectFootnote(onSelectFootnote), + selectedIndex(0) {} + + void onEnter() override; + void onExit() override; + void handleInput() override; + +private: + void render(); +}; \ No newline at end of file diff --git a/src/screens/EpubReaderMenuScreen.cpp b/src/screens/EpubReaderMenuScreen.cpp new file mode 100644 index 0000000..0c992b6 --- /dev/null +++ b/src/screens/EpubReaderMenuScreen.cpp @@ -0,0 +1,98 @@ +// +// Created by jlaunay on 13/12/2025. +// +#include "EpubReaderMenuScreen.h" +#include +#include "config.h" + +constexpr int MENU_ITEMS_COUNT = 2; + +void EpubReaderMenuScreen::taskTrampoline(void* param) { + auto* self = static_cast(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(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(); +} \ No newline at end of file diff --git a/src/screens/EpubReaderMenuScreen.h b/src/screens/EpubReaderMenuScreen.h new file mode 100644 index 0000000..fac8d79 --- /dev/null +++ b/src/screens/EpubReaderMenuScreen.h @@ -0,0 +1,45 @@ +// +// Created by jlaunay on 13/12/2025. +// + +#ifndef CROSSPOINT_READER_EPUBREADERMENUSCREEN_H +#define CROSSPOINT_READER_EPUBREADERMENUSCREEN_H +#pragma once +#include +#include +#include + +#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 onGoBack; + const std::function onSelectOption; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + +public: + explicit EpubReaderMenuScreen(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onGoBack, + const std::function& onSelectOption) + : Screen(renderer, inputManager), + onGoBack(onGoBack), + onSelectOption(onSelectOption) {} + + void onEnter() override; + void onExit() override; + void handleInput() override; +}; +#endif // CROSSPOINT_READER_EPUBREADERMENUSCREEN_H diff --git a/src/screens/EpubReaderScreen.cpp b/src/screens/EpubReaderScreen.cpp index 4e13f3d..24f674e 100644 --- a/src/screens/EpubReaderScreen.cpp +++ b/src/screens/EpubReaderScreen.cpp @@ -1,5 +1,6 @@ #include "EpubReaderScreen.h" +#include "EpubReaderFootnotesScreen.h" #include #include #include @@ -7,6 +8,7 @@ #include "Battery.h" #include "CrossPointSettings.h" #include "EpubReaderChapterSelectionScreen.h" +#include "EpubReaderMenuScreen.h" #include "config.h" constexpr int PAGES_PER_REFRESH = 15; @@ -28,7 +30,6 @@ void EpubReaderScreen::onEnter() { } renderingMutex = xSemaphoreCreateMutex(); - epub->setupCacheDir(); if (SD.exists((epub->getCachePath() + "/progress.bin").c_str())) { @@ -45,10 +46,10 @@ void EpubReaderScreen::onEnter() { updateRequired = true; xTaskCreate(&EpubReaderScreen::taskTrampoline, "EpubReaderScreenTask", - 8192, // Stack size - this, // Parameters - 1, // Priority - &displayTaskHandle // Task handle + 24576, //32768 + this, + 1, + &displayTaskHandle ); } @@ -72,27 +73,79 @@ void EpubReaderScreen::handleInput() { return; } - // Enter chapter selection screen + // Enter Menu selection screen + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + if (isViewingFootnote) { + restoreSavedPosition(); + updateRequired = true; + return; + } else { + onGoHome(); + return; + } + } if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { // Don't start screen transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); - subScreen.reset(new EpubReaderChapterSelectionScreen( - this->renderer, this->inputManager, epub, currentSpineIndex, + + subScreen.reset(new EpubReaderMenuScreen( + this->renderer, this->inputManager, [this] { + // onGoBack - return to reading subScreen->onExit(); subScreen.reset(); updateRequired = true; }, - [this](const int newSpineIndex) { - if (currentSpineIndex != newSpineIndex) { - currentSpineIndex = newSpineIndex; - nextPageNumber = 0; - section.reset(); + [this](EpubReaderMenuScreen::MenuOption option) { + // onSelectOption - handle menu choice + if (option == EpubReaderMenuScreen::CHAPTERS) { + // Show chapter selection + 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(); xSemaphoreGive(renderingMutex); } @@ -111,7 +164,7 @@ void EpubReaderScreen::handleInput() { 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()) { currentSpineIndex = epub->getSpineItemsCount() - 1; nextPageNumber = UINT16_MAX; @@ -177,17 +230,16 @@ void EpubReaderScreen::displayTaskLoop() { } } -// TODO: Failure handling void EpubReaderScreen::renderScreen() { if (!epub) { return; } - // edge case handling for sub-zero spine index + // Edge case handling for sub-zero spine index if (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()) { currentSpineIndex = epub->getSpineItemsCount(); } @@ -262,27 +314,40 @@ void EpubReaderScreen::renderScreen() { return; } - { - auto p = section->loadPageFromSD(); - if (!p) { - Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis()); - section->clearCache(); - section.reset(); - return renderScreen(); - } - const auto start = millis(); - renderContents(std::move(p)); - Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); + // Load page from SD - use pointer to avoid copying on stack + std::unique_ptr p = section->loadPageFromSD(); + if (!p) { + Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis()); + section->clearCache(); + section.reset(); + return renderScreen(); } + // 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); - uint8_t data[4]; - data[0] = currentSpineIndex & 0xFF; - data[1] = (currentSpineIndex >> 8) & 0xFF; - data[2] = section->currentPage & 0xFF; - data[3] = (section->currentPage >> 8) & 0xFF; - f.write(data, 4); - f.close(); + if (f) { + uint8_t data[4]; + data[0] = currentSpineIndex & 0xFF; + data[1] = (currentSpineIndex >> 8) & 0xFF; + data[2] = section->currentPage & 0xFF; + data[3] = (section->currentPage >> 8) & 0xFF; + f.write(data, 4); + f.close(); + } } void EpubReaderScreen::renderContents(std::unique_ptr page) { @@ -325,18 +390,19 @@ void EpubReaderScreen::renderContents(std::unique_ptr page) { void EpubReaderScreen::renderStatusBar() const { constexpr auto textY = 776; // Right aligned text for progress counter - const std::string progress = std::to_string(section->currentPage + 1) + " / " + std::to_string(section->pageCount); - const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); - renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, - progress.c_str()); + char progressBuf[32]; // Use fixed buffer instead of std::string + snprintf(progressBuf, sizeof(progressBuf), "%d / %d", section->currentPage + 1, section->pageCount); + const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressBuf); + renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, progressBuf); // Left aligned battery icon and percentage const uint16_t percentage = battery.readPercentage(); - const auto percentageText = std::to_string(percentage) + "%"; - const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); + char percentageBuf[8]; // Use fixed buffer instead of std::string + snprintf(percentageBuf, sizeof(percentageBuf), "%d%%", percentage); + 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 batteryHeight = 10; constexpr int x = marginLeft; @@ -354,34 +420,150 @@ void EpubReaderScreen::renderStatusBar() const { 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); - // 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; if (filledWidth > batteryWidth - 5) { - filledWidth = batteryWidth - 5; // Ensure we don't overflow + filledWidth = batteryWidth - 5; } renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); - // Centered chatper title text - // Page width minus existing content with 30px padding on each side + // Centered chapter title text const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; const int titleMarginRight = progressTextWidth + 30 + marginRight; const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); - std::string title; - int titleWidth; if (tocIndex == -1) { - title = "Unnamed"; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); + const char* title = "Unnamed"; + const int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title); + renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title); } else { - const auto tocItem = epub->getTocItem(tocIndex); - title = tocItem.title; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); - while (titleWidth > availableTextWidth) { + const auto& tocItem = epub->getTocItem(tocIndex); + std::string title = tocItem.title; + int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + + // Truncate title if too long + while (titleWidth > availableTextWidth && title.length() > 8) { title = title.substr(0, title.length() - 8) + "..."; 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 for this anchor + if (!anchor.empty()) { + 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 (targetSpineIndex != -1) { + Serial.printf("[%lu] [ERS] Found inline footnote at index: %d\n", + millis(), targetSpineIndex); + + // Navigate to inline footnote + xSemaphoreTake(renderingMutex, portMAX_DELAY); + currentSpineIndex = targetSpineIndex; + nextPageNumber = 0; + section.reset(); + xSemaphoreGive(renderingMutex); + + updateRequired = true; + return; + } else { + Serial.printf("[%lu] [ERS] No inline footnote 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; + } } diff --git a/src/screens/EpubReaderScreen.h b/src/screens/EpubReaderScreen.h index 4ef8bef..7eaac5a 100644 --- a/src/screens/EpubReaderScreen.h +++ b/src/screens/EpubReaderScreen.h @@ -4,6 +4,7 @@ #include #include #include +#include "EpubReaderFootnotesScreen.h" #include "Screen.h" @@ -18,6 +19,11 @@ class EpubReaderScreen final : public Screen { int pagesUntilFullRefresh = 0; bool updateRequired = false; const std::function onGoHome; + FootnotesData currentPageFootnotes; + + int savedSpineIndex = -1; + int savedPageNumber = -1; + bool isViewingFootnote = false; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); @@ -25,11 +31,15 @@ class EpubReaderScreen final : public Screen { void renderContents(std::unique_ptr p); 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, const std::function& onGoHome) : Screen(renderer, inputManager), epub(std::move(epub)), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void handleInput() override; -}; +}; \ No newline at end of file