Compare commits

...

15 Commits

Author SHA1 Message Date
Uri Tauber
ce9ae77f79 Fix footnote placement for paragraphs spanning multiple pages 2026-01-30 11:09:52 +02:00
Uri Tauber
c51d617368 Fix footnote placement for paragraphs spanning multiple pages 2026-01-30 10:52:37 +02:00
Xuan-Son Nguyen
0e46822a8b feat: add HalDisplay and HalGPIO (#522)
## Summary

Extracted some changes from
https://github.com/crosspoint-reader/crosspoint-reader/pull/500 to make
reviewing easier

This PR adds HAL (Hardware Abstraction Layer) for display and GPIO
components, making it easier to write a stub or an emulated
implementation of the hardware.

SD card HAL will be added via another PR, because it's a bit more
tricky.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **NO**
2026-01-30 10:46:02 +02:00
Eliz
8b8cca2d9b feat: Display epub metadata on Recents (#511)
* **What is the goal of this PR?** Implement a metadata viewer for the
Recents screen
* **What changes are included?**

| Recents | Files |
| --- | --- |
| <img alt="image"
src="https://github.com/user-attachments/assets/e0f2d816-ddce-4a2e-bd4a-cd431d0e6532"
/> | <img alt="image"
src="https://github.com/user-attachments/assets/3225cdce-d501-4175-bc92-73cb8bfe7a41"
/> |

For the Files screen, I have not made any changes on purpose. For the
Recents screen, we now display the Book title and author. If it is a
file with no epub metadata like txt or md, we display the file name
without the file extension.

---

Did you use AI tools to help write this code? _**< YES  >**_

Although I went trough all the code manually and made changes as well,
please be aware the majority of the code is AI generated.

---------

Co-authored-by: Eliz Kilic <elizk@google.com>
2026-01-30 10:46:02 +02:00
Dave Allie
9dc57baf19 chore: Cut release 0.16.0 2026-01-30 10:46:02 +02:00
Dave Allie
6ff464ccb1 fix: Correctly render italics on image alt placeholders (#569)
## Summary

* Correctly render italics on image alt placeholders
  * Parser incorrectly handled depth of self-closing tags
  * Self-closing tags immediately call start and end tag

## Additional Context

* Previously, it would incorrectly make the whole chapter bold/italics,
or not italicised the image alt

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-01-30 10:46:01 +02:00
Boris Faure
824607d3fd chore: .gitignore: add compile_commands.json & .cache (#568)
## Summary

* **What is the goal of this PR?**
Quality of Life

* **What changes are included?**
Add compile_commands.json & .cache to .gitignore .

Both are use by clangd that can help IDE support.

Run `pio run --target compiledb` to generate `compile_commands.json`.


---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-01-30 10:46:01 +02:00
Dave Allie
ff49d252ae fix: Render keyboard entry over multiple lines (#567)
## Summary

* Render keyboard entry over multiple lines
  * Grows display areas based on input text
  * Shown on OPDS entry, but applies everywhere

## Additional Context

* Fixes
https://github.com/crosspoint-reader/crosspoint-reader/issues/554

| One line | Multi-line |
| --- | --- |
|
![IMG_5925](https://github.com/user-attachments/assets/28be00a8-7b90-4bf6-9ebf-4d4ad6642bc9)
|
![IMG_5926](https://github.com/user-attachments/assets/1c69a96f-d868-49a1-866c-546ca7b784ab)
|

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-01-30 10:46:01 +02:00
Arthur Tazhitdinov
aa5875244d fix: missing front layout in mapLabels() (#564)
## Summary

* adds missing front layout to mapLabels function
2026-01-30 10:46:01 +02:00
V
1931cc6178 refactor: Re-work for OTA feature (#509)
## Summary

Finally, I have received my device and got to chance to work on OTA. 
https://github.com/crosspoint-reader/crosspoint-reader/issues/176

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
Existing OTA functionality is very buggy, many of times (I would say 8
out of 10) are end up with fail for me. When the time that it works it
is very slow and take ages. For others looks like end up with crash or
different issues.


* **What changes are included?**
To be honest, I'm not familiar with Arduino APIs of OTA process, but
looks like not good as much esp-idf itself. I always found Arduino APIs
very bulky for esp32. Wrappers and wrappers.

## Additional Context
Right now, OTA takes ~ 3min 10sec (of course depends on size of .bin
file). Can be tested with playing version info inside from
`platform.ini` file.

```
[crosspoint]
version = 0.14.0
```
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< NO >**_
2026-01-30 10:46:01 +02:00
Daniel Chelling
b64f2a941d perf: optimize large EPUB indexing from O(n^2) to O(n) (#458)
## Summary

Optimizes EPUB metadata indexing for large books (2000+ chapters) from
~30 minutes to ~50 seconds by replacing O(n²) algorithms with O(n log n)
hash-indexed lookups.

Fixes #134

## Problem

Three phases had O(n²) complexity due to nested loops:

| Phase | Operation | Before (2768 chapters) |
|-------|-----------|------------------------|
| OPF Pass | For each spine ref, scan all manifest items | ~25 min |
| TOC Pass | For each TOC entry, scan all spine items | ~5 min |
| buildBookBin | For each spine item, scan ZIP central directory | ~8.4
min |

Total: **~30+ minutes** for first-time indexing of large EPUBs.

## Solution

Replace linear scans with sorted hash indexes + binary search:

- **OPF Pass**: Build `{hash(id), len, offset}` index from manifest,
binary search for each spine ref
- **TOC Pass**: Build `{hash(href), len, spineIndex}` index from spine,
binary search for each TOC entry
- **buildBookBin**: New `ZipFile::fillUncompressedSizes()` API - single
ZIP central directory scan with batch hash matching

All indexes use FNV-1a hashing with length as secondary key to minimize
collisions. Indexes are freed immediately after each phase.

## Results

**Shadow Slave EPUB (2768 chapters):**

| Phase | Before | After | Speedup |
|-------|--------|-------|---------|
| OPF pass | ~25 min | 10.8 sec | ~140x |
| TOC pass | ~5 min | 4.7 sec | ~60x |
| buildBookBin | 506 sec | 34.6 sec | ~15x |
| **Total** | **~30+ min** | **~50 sec** | **~36x** |

**Normal EPUB (87 chapters):** 1.7 sec - no regression.

## Memory

Peak temporary memory during indexing:
- OPF index: ~33KB (2770 items × 12 bytes)
- TOC index: ~33KB (2768 items × 12 bytes)
- ZIP batch: ~44KB (targets + sizes arrays)

All indexes cleared immediately after each phase. No OOM risk on
ESP32-C3.

## Note on Threshold

All optimizations are gated by `LARGE_SPINE_THRESHOLD = 400` to preserve
existing behavior for small books. However, the algorithms work
correctly for any book size and are faster even for small books:

| Book Size | Old O(n²) | New O(n log n) | Improvement |
|-----------|-----------|----------------|-------------|
| 10 ch | 100 ops | 50 ops | 2x |
| 100 ch | 10K ops | 800 ops | 12x |
| 400 ch | 160K ops | 4K ops | 40x |

If preferred, the threshold could be removed to use the optimized path
universally.

## Testing

- [x] Shadow Slave (2768 chapters): 50s first-time indexing, loads and
navigates correctly
- [x] Normal book (87 chapters): 1.7s indexing, no regression
- [x] Build passes
- [x] clang-format passes

## Files Changed

- `lib/Epub/Epub/parsers/ContentOpfParser.h/.cpp` - OPF manifest index
- `lib/Epub/Epub/BookMetadataCache.h/.cpp` - TOC index + batch size
lookup
- `lib/ZipFile/ZipFile.h/.cpp` - New `fillUncompressedSizes()` API
- `lib/Epub/Epub.cpp` - Timing logs

<details>
<summary><b>Algorithm Details</b> (click to expand)</summary>

### Phase 1: OPF Pass - Manifest to Spine Lookup

**Problem**: Each `<itemref idref="ch001">` in spine must find matching
`<item id="ch001" href="...">` in manifest.

```
OLD: For each of 2768 spine refs, scan all 2770 manifest items
     = 7.6M string comparisons

NEW: While parsing manifest, build index:
     { hash("ch001"), len=5, file_offset=120 }
     
     Sort index, then binary search for each spine ref:
     2768 × log₂(2770) ≈ 2768 × 11 = 30K comparisons
```

### Phase 2: TOC Pass - TOC Entry to Spine Index Lookup

**Problem**: Each TOC entry with `href="chapter0001.xhtml"` must find
its spine index.

```
OLD: For each of 2768 TOC entries, scan all 2768 spine entries
     = 7.6M string comparisons

NEW: At beginTocPass(), read spine once and build index:
     { hash("OEBPS/chapter0001.xhtml"), len=25, spineIndex=0 }
     
     Sort index, binary search for each TOC entry:
     2768 × log₂(2768) ≈ 30K comparisons
     
     Clear index at endTocPass() to free memory.
```

### Phase 3: buildBookBin - ZIP Size Lookup

**Problem**: Need uncompressed file size for each spine item (for
reading progress). Sizes are in ZIP central directory.

```
OLD: For each of 2768 spine items, scan ZIP central directory (2773 entries)
     = 7.6M filename reads + string comparisons
     Time: 506 seconds

NEW: 
  Step 1: Build targets from spine
          { hash("OEBPS/chapter0001.xhtml"), len=25, index=0 }
          Sort by (hash, len)
  
  Step 2: Single pass through ZIP central directory
          For each entry:
            - Compute hash ON THE FLY (no string allocation)
            - Binary search targets
            - If match: sizes[target.index] = uncompressedSize
  
  Step 3: Use sizes array directly (O(1) per spine item)
  
  Total: 2773 entries × log₂(2768) ≈ 33K comparisons
  Time: 35 seconds
```

### Why Hash + Length?

Using 64-bit FNV-1a hash + string length as a composite key:
- Collision probability: ~1 in 2⁶⁴ × typical_path_lengths
- No string storage needed in index (just 12-16 bytes per entry)
- Integer comparisons are faster than string comparisons
- Verification on match handles the rare collision case

</details>

---

_AI-assisted development. All changes tested on hardware._
2026-01-30 10:46:01 +02:00
Lalo
11d30e7ca8 feat: Add Spanish hyphenation support (#558)
## Summary

* **What is the goal of this PR?** Add Spanish language hyphenation
support to improve text rendering for Spanish books.
* **What changes are included?**
- Added Spanish hyphenation trie (`hyph-es.trie.h`) generated from
Typst's hypher patterns
- Registered `spanishHyphenator` in `LanguageRegistry.cpp` for language
tag `es`
  - Added Spanish to the hyphenation evaluation test suite
  - Added Spanish test data file with 5000 test cases

## Additional Context

* **Test Results:** Spanish hyphenation achieves 99.02% F1 Score (97.72%
perfect matches out of 5000 test cases)
* **Compatibility:** Works automatically for EPUBs with
`<dc:language>es</dc:language>` (or es-ES, es-MX, etc.)
<img width="115" height="189" alt="imagen"
src="https://github.com/user-attachments/assets/9b92e7fc-b98d-48af-8d53-dfdc2e68abee"
/>


| Metric | Value |
|--------|-------|
| Perfect matches | 97.72% |
| Overall Precision | 99.33% |
| Overall Recall | 99.42% |
| Overall F1 Score | 99.38% |

---

### AI Usage

Did you use AI tools to help write this code? _**PARTIALLY**_

AI assisted with:
- Guiding and compile
- Preparing the PR description
2026-01-30 10:46:01 +02:00
Eliz
5fa58a002c feat: Add support to B&W filters to image covers (#476)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
Implementation of a new feature in Display options as Image Filter
* **What changes are included?**
Black & White and Inverted Black & White options are added.

## Additional Context

Here are some examples:

| None | Contrast | Inverted |
| --- | --- | --- |
| <img alt="image"
src="https://github.com/user-attachments/assets/fe02dd9b-f647-41bd-8495-c262f73177c4"
/> | <img alt="image"
src="https://github.com/user-attachments/assets/2d17747d-3ff6-48a9-b9b9-eb17cccf19cf"
/> | <img alt="image"
src="https://github.com/user-attachments/assets/792dea50-f003-4634-83fe-77849ca49095"
/> |
| <img alt="image"
src="https://github.com/user-attachments/assets/28395b63-14f8-41e2-886b-8ddf3faeafc4"
/> | <img alt="image"
src="https://github.com/user-attachments/assets/71a569c8-fc54-4647-ad4c-ec96e220cddb"
/> | <img alt="image"
src="https://github.com/user-attachments/assets/9139e32c-9175-433e-8372-45fa042d3dc9"
/> |



* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

I have also tried adding Color inversion, but could not see much
difference with that. It might be because my implementation was wrong.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _** PARTIALLY **_

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-30 10:46:01 +02:00
Uri Tauber
e1849983b8 refactor: use static design for reader TOC to avoid memory fragmentation 2026-01-30 10:46:01 +02:00
Uri Tauber
2df7a3124f feat: consolidate reader menu into tabbed TOC view 2026-01-30 10:46:01 +02:00
5 changed files with 101 additions and 73 deletions

View File

@ -49,17 +49,25 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
} // namespace
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle) {
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
std::unique_ptr<FootnoteEntry> footnote) {
if (word.empty()) return;
words.push_back(std::move(word));
wordStyles.push_back(fontStyle);
if (footnote) {
wordHasFootnote.push_back(1);
footnoteQueue.push_back(*footnote);
} else {
wordHasFootnote.push_back(0);
}
}
// Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const bool includeLastLine) {
void ParsedText::layoutAndExtractLines(
const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>, const std::vector<FootnoteEntry>&)>& processLine,
const bool includeLastLine) {
if (words.empty()) {
return;
}
@ -255,8 +263,8 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
return lineBreakIndices;
}
// Splits words[wordIndex] into prefix (adding a hyphen only when needed) and remainder when a legal breakpoint fits the
// available width.
// Splits words[wordIndex] into prefix (adding a hyphen only when needed)
// and remainder when a legal breakpoint fits the available width.
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
const int fontId, std::vector<uint16_t>& wordWidths,
const bool allowFallbackBreaks) {
@ -320,6 +328,13 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
words.insert(insertWordIt, remainder);
wordStyles.insert(insertStyleIt, style);
// Split wordHasFootnote as well. The footnote (if any) is associated with the remainder word.
auto wordHasFootnoteIt = wordHasFootnote.begin();
std::advance(wordHasFootnoteIt, wordIndex);
uint8_t hasFootnote = *wordHasFootnoteIt;
*wordHasFootnoteIt = 0; // First part doesn't have it anymore
wordHasFootnote.insert(std::next(wordHasFootnoteIt), hasFootnote);
// Update cached widths to reflect the new prefix/remainder pairing.
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
@ -327,9 +342,10 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
return true;
}
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
void ParsedText::extractLine(
const size_t breakIndex, const int pageWidth, const int spaceWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>, const std::vector<FootnoteEntry>&)>& processLine) {
const size_t lineBreak = lineBreakIndices[breakIndex];
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
const size_t lineWordCount = lineBreak - lastBreakAt;
@ -372,17 +388,35 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
std::advance(wordEndIt, lineWordCount);
std::advance(wordStyleEndIt, lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
// Extract footnote flags from deque
std::vector<FootnoteEntry> lineFootnotes;
for (size_t i = 0; i < lineWordCount; i++) {
if (!wordHasFootnote.empty()) {
uint8_t hasFn = wordHasFootnote.front();
wordHasFootnote.pop_front();
if (hasFn) {
if (footnoteQueue.empty()) {
Serial.printf("[%lu] [ERROR] Footnote flag set but queue empty! Flags/queue out of sync.\n", millis());
break;
}
lineFootnotes.push_back(footnoteQueue.front());
footnoteQueue.pop_front();
}
}
}
for (auto& word : lineWords) {
if (containsSoftHyphen(word)) {
stripSoftHyphensInPlace(word);
}
}
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style));
}
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style),
lineFootnotes);
}

View File

@ -2,12 +2,14 @@
#include <EpdFontFamily.h>
#include <deque>
#include <functional>
#include <list>
#include <memory>
#include <string>
#include <vector>
#include "FootnoteEntry.h"
#include "blocks/TextBlock.h"
class GfxRenderer;
@ -15,6 +17,8 @@ class GfxRenderer;
class ParsedText {
std::list<std::string> words;
std::list<EpdFontFamily::Style> wordStyles;
std::deque<uint8_t> wordHasFootnote;
std::deque<FootnoteEntry> footnoteQueue;
TextBlock::Style style;
bool extraParagraphSpacing;
bool hyphenationEnabled;
@ -26,9 +30,10 @@ class ParsedText {
int spaceWidth, std::vector<uint16_t>& wordWidths);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
void extractLine(
size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>, const std::vector<FootnoteEntry>&)>& processLine);
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
public:
@ -37,12 +42,13 @@ class ParsedText {
: style(style), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
~ParsedText() = default;
void addWord(std::string word, EpdFontFamily::Style fontStyle);
void addWord(std::string word, EpdFontFamily::Style fontStyle, std::unique_ptr<FootnoteEntry> footnote = nullptr);
void setStyle(const TextBlock::Style style) { this->style = style; }
TextBlock::Style getStyle() const { return style; }
size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true);
};
void layoutAndExtractLines(
const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>, const std::vector<FootnoteEntry>&)>& processLine,
bool includeLastLine = true);
};

View File

@ -116,14 +116,14 @@ void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) {
currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing, hyphenationEnabled));
}
void ChapterHtmlSlimParser::addFootnoteToCurrentPage(const char* number, const char* href) {
if (currentPageFootnoteCount >= 16) return;
std::unique_ptr<FootnoteEntry> ChapterHtmlSlimParser::createFootnoteEntry(const char* number, const char* href) {
auto entry = std::unique_ptr<FootnoteEntry>(new FootnoteEntry());
Serial.printf("[%lu] [ADDFT] Adding footnote: num=%s, href=%s\n", millis(), number, href);
Serial.printf("[%lu] [ADDFT] Creating footnote: num=%s, href=%s\n", millis(), number, href);
// Copy number
strncpy(currentPageFootnotes[currentPageFootnoteCount].number, number, 2);
currentPageFootnotes[currentPageFootnoteCount].number[2] = '\0';
strncpy(entry->number, number, 2);
entry->number[2] = '\0';
// Check if this is an inline footnote reference
const char* hashPos = strchr(href, '#');
@ -138,8 +138,8 @@ void ChapterHtmlSlimParser::addFootnoteToCurrentPage(const char* number, const c
char rewrittenHref[64];
snprintf(rewrittenHref, sizeof(rewrittenHref), "inline_%s.html#%s", inlineId, inlineId);
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, rewrittenHref, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
strncpy(entry->href, rewrittenHref, 63);
entry->href[63] = '\0';
Serial.printf("[%lu] [ADDFT] Rewrote inline href to: %s\n", millis(), rewrittenHref);
foundInline = true;
@ -154,8 +154,8 @@ void ChapterHtmlSlimParser::addFootnoteToCurrentPage(const char* number, const c
char rewrittenHref[64];
snprintf(rewrittenHref, sizeof(rewrittenHref), "pnote_%s.html#%s", inlineId, inlineId);
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, rewrittenHref, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
strncpy(entry->href, rewrittenHref, 63);
entry->href[63] = '\0';
Serial.printf("[%lu] [ADDFT] Rewrote paragraph note href to: %s\n", millis(), rewrittenHref);
foundInline = true;
@ -166,20 +166,17 @@ void ChapterHtmlSlimParser::addFootnoteToCurrentPage(const char* number, const c
if (!foundInline) {
// Normal href, just copy it
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, href, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
strncpy(entry->href, href, 63);
entry->href[63] = '\0';
}
} else {
// No anchor, just copy
strncpy(currentPageFootnotes[currentPageFootnoteCount].href, href, 63);
currentPageFootnotes[currentPageFootnoteCount].href[63] = '\0';
strncpy(entry->href, href, 63);
entry->href[63] = '\0';
}
currentPageFootnoteCount++;
Serial.printf("[%lu] [ADDFT] Stored as: num=%s, href=%s\n", millis(),
currentPageFootnotes[currentPageFootnoteCount - 1].number,
currentPageFootnotes[currentPageFootnoteCount - 1].href);
Serial.printf("[%lu] [ADDFT] Created as: num=%s, href=%s\n", millis(), entry->number, entry->href);
return entry;
}
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
@ -593,7 +590,10 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis());
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->viewportWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
[self](const std::shared_ptr<TextBlock>& textBlock, const std::vector<FootnoteEntry>& footnotes) {
self->addLineToPage(textBlock, footnotes);
},
false);
}
}
@ -688,18 +688,17 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
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);
// Create the footnote entry (this does the rewriting)
std::unique_ptr<FootnoteEntry> footnote =
self->createFootnoteEntry(self->currentNoterefText, self->currentNoterefHref);
// Then call callback with the REWRITTEN href from currentPageFootnotes
if (self->noterefCallback && self->currentPageFootnoteCount > 0) {
// Then call callback with the REWRITTEN href
if (self->noterefCallback && footnote) {
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);
strncpy(noteref.href, footnote->href, 127);
noteref.href[127] = '\0';
self->noterefCallback(noteref);
@ -712,9 +711,9 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
char formattedNoteref[32];
snprintf(formattedNoteref, sizeof(formattedNoteref), "[%s]", self->currentNoterefText);
// Add it as a word to the current text block
// Add it as a word to the current text block with the footnote attached
if (self->currentTextBlock) {
self->currentTextBlock->addWord(formattedNoteref, fontStyle);
self->currentTextBlock->addWord(formattedNoteref, fontStyle, std::move(footnote));
}
}
@ -846,7 +845,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
partWordBufferIndex = 0;
insideNoteref = false;
insideAsideFootnote = false;
currentPageFootnoteCount = 0;
isPass1CollectingAsides = false;
supDepth = -1;
@ -928,10 +926,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
makePages();
if (currentPage) {
for (int i = 0; i < currentPageFootnoteCount; i++) {
currentPage->addFootnote(currentPageFootnotes[i].number, currentPageFootnotes[i].href);
}
currentPageFootnoteCount = 0;
completePageFn(std::move(currentPage));
}
@ -942,17 +936,11 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return true;
}
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line,
const std::vector<FootnoteEntry>& footnotes) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
if (currentPageNextY + lineHeight > viewportHeight) {
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 = 0;
@ -961,6 +949,11 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
if (currentPage && currentPage->elements.size() < 24) { // Assuming generic capacity check or vector size
currentPage->elements.push_back(std::make_shared<PageLine>(line, 0, currentPageNextY));
currentPageNextY += lineHeight;
// Add footnotes for this line to the current page
for (const auto& fn : footnotes) {
currentPage->addFootnote(fn.number, fn.href);
}
} else if (currentPage) {
Serial.printf("[%lu] [EHP] WARNING: Page element capacity reached, skipping element\n", millis());
}
@ -980,7 +973,9 @@ void ChapterHtmlSlimParser::makePages() {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
currentTextBlock->layoutAndExtractLines(
renderer, fontId, viewportWidth,
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
[this](const std::shared_ptr<TextBlock>& textBlock, const std::vector<FootnoteEntry>& footnotes) {
addLineToPage(textBlock, footnotes);
});
// Extra paragraph spacing if enabled
if (extraParagraphSpacing) {
currentPageNextY += lineHeight / 2;

View File

@ -77,10 +77,6 @@ class ChapterHtmlSlimParser {
int currentNoterefHrefLen = 0;
std::function<void(Noteref&)> noterefCallback = nullptr;
// Footnote tracking for current page
FootnoteEntry currentPageFootnotes[16];
int currentPageFootnoteCount = 0;
// Inline footnotes (aside) tracking
bool insideAsideFootnote = false;
int asideDepth = 0;
@ -106,7 +102,7 @@ class ChapterHtmlSlimParser {
int supDepth = -1;
int anchorDepth = -1;
void addFootnoteToCurrentPage(const char* number, const char* href);
std::unique_ptr<FootnoteEntry> createFootnoteEntry(const char* number, const char* href);
void startNewTextBlock(TextBlock::Style style);
EpdFontFamily::Style getCurrentFontStyle() const;
void flushPartWordBuffer();
@ -161,7 +157,7 @@ class ChapterHtmlSlimParser {
}
bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);
void addLineToPage(std::shared_ptr<TextBlock> line, const std::vector<FootnoteEntry>& footnotes);
void setNoterefCallback(const std::function<void(Noteref&)>& callback) { noterefCallback = callback; }
};

View File

@ -112,20 +112,17 @@ void EpubReaderTocActivity::loopChapters() {
const int totalItems = getChaptersTotalItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (isSyncItem(chaptersSelectorIndex)) {
if (hasSyncOption() && (chaptersSelectorIndex == 0 || chaptersSelectorIndex == totalItems - 1)) {
launchSyncActivity();
return;
}
int filteredIndex = chaptersSelectorIndex;
if (hasSyncOption() && chaptersSelectorIndex > 0) filteredIndex -= 1;
if (hasSyncOption()) filteredIndex -= 1;
if (filteredIndex >= 0 && filteredIndex < static_cast<int>(filteredSpineIndices.size())) {
onSelectSpineIndex(filteredSpineIndices[filteredIndex]);
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (upReleased) {
if (totalItems > 0) {
if (skipPage) {