Compare commits
14 Commits
bc6dc357eb
...
1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48267ad848 | ||
|
|
dd630dcf72 | ||
|
|
ef705d3ac6 | ||
|
|
bab374a675 | ||
|
|
c171813045 | ||
|
|
d5e42b9e40 | ||
|
|
168c8fdb69 | ||
|
|
492cf976f5 | ||
|
|
25e255af50 | ||
|
|
a4adbb9dfe | ||
|
|
6ceba56620 | ||
|
|
62643ae933 | ||
|
|
8b41dccfb9 | ||
|
|
3204fa0339 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,6 +13,9 @@ test/epubs/
|
||||
CrossPoint-ef.md
|
||||
Serial_print.code-search
|
||||
|
||||
# Gitea Release note drafts
|
||||
release-notes-*.md
|
||||
|
||||
# Gitea Actions runner config (contains credentials)
|
||||
.runner
|
||||
.runner.*
|
||||
|
||||
139
ef-CHANGELOG.md
Normal file
139
ef-CHANGELOG.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# crosspoint-ef Changelog
|
||||
|
||||
All notable changes to the crosspoint-ef fork are documented here.
|
||||
|
||||
Base: CrossPoint Reader 0.15.0
|
||||
|
||||
---
|
||||
|
||||
## ef-1.0.4
|
||||
|
||||
**EPUB Rendering & Stability**
|
||||
|
||||
### New Features
|
||||
|
||||
- **End-of-Book "Start Over"**: Press next at end of book to wrap to first page
|
||||
|
||||
### EPUB Rendering Improvements
|
||||
|
||||
- CSS `margin-left`/`padding-left` parsing for block indentation
|
||||
- Vertical bar and italic styling for blockquotes
|
||||
- Left margin indentation for list items (`<ol>`/`<ul>`)
|
||||
- Fixed ordered lists showing bullets instead of numbers
|
||||
- Fixed nested `<p>` inside `<li>` causing marker on separate line
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Webserver**: Fixed file listing disconnection issues with flow control
|
||||
- **Webserver**: Memory optimization for File Transfer mode (frees heap before starting)
|
||||
- **Dictionary**: Fixed zip dictionary allocation order for better memory allocation success
|
||||
|
||||
---
|
||||
|
||||
## ef-1.0.3
|
||||
|
||||
**Maintenance Release**
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed cppcheck CI failure: removed unused `screenWidth` variable in word selection activity
|
||||
|
||||
---
|
||||
|
||||
## ef-1.0.2
|
||||
|
||||
**Quick Menu Enhancements**
|
||||
|
||||
### New Features
|
||||
|
||||
- **Screen Rotation Toggle**: Quick toggle between Portrait and Landscape CCW directly from the quick menu
|
||||
- Automatically reindexes content for new screen dimensions
|
||||
- Preserves reading position via content offset restoration
|
||||
- **Customizable Menu Order**: Reorder quick menu items to your preference
|
||||
- New "Edit List Order" option at bottom of menu
|
||||
- Pick-and-place reordering: select item, navigate to destination, place
|
||||
- Order persists across sessions
|
||||
|
||||
### UI Improvements
|
||||
|
||||
- Added navigation button hints to quick menu (prev/next on front buttons, up/down on side buttons)
|
||||
- Fixed orientation-aware margins for button hint areas in landscape modes
|
||||
- New default menu order: Bookmark, Dictionary, Rotate Screen, Settings, Clear Cache
|
||||
|
||||
---
|
||||
|
||||
## ef-1.0.1
|
||||
|
||||
**Dictionary Stability & UX Improvements**
|
||||
|
||||
### Bug Fixes - Stability
|
||||
|
||||
- Fixed dictionary crashes caused by heap fragmentation from repeated page navigation
|
||||
- Refactored TextBlock/ParsedText from `std::list` to `std::vector`, reducing heap allocations by ~12x per TextBlock
|
||||
- Affects EPUB reader page rendering, dictionary definition display, and word selection
|
||||
- Contiguous memory improves cache locality during text layout and reduces heap fragmentation on the memory-constrained ESP32
|
||||
- Added uncompressed dictionary (`.dict`) support to avoid decompression memory issues with large dictzip chunks (58KB chunks -> direct read)
|
||||
- Implemented chunked on-demand HTML parsing for large definitions, parsing pages as user navigates rather than all at once
|
||||
- Limited cached pages to 4 with re-parse capability for backward navigation beyond cache window
|
||||
- Fixed double-button press bug when loading new dictionary chunks
|
||||
|
||||
### Bug Fixes - UI/Layout
|
||||
|
||||
- Restored proper orientation-aware button hint spacing (front: 45px, side: 50px)
|
||||
- Added side button hints to definition screen with "<" / ">" labels for page navigation
|
||||
- Added side button hints to word selection screen ("UP"/"DOWN" labels, borderless, small font)
|
||||
- Added side button hints to dictionary menu ("< Prev", "Next >")
|
||||
- Moved page indicator up to avoid bezel cutoff in landscape orientations
|
||||
|
||||
---
|
||||
|
||||
## ef-1.0.0
|
||||
|
||||
**First Official Release** (previously ef-0.15.99)
|
||||
|
||||
First milestone release of the crosspoint-ef fork, building on CrossPoint Reader 0.15.0 with 14+ major new features and enhancements.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Dictionary Support**: Offline StarDict dictionary with word selection from reader, fast prefix-indexed search, rich HTML formatting, and multi-page pagination
|
||||
- **Bookmark System**: Per-book bookmarks with visual folded-corner indicators, dedicated management interface, and auto-generated bookmark names
|
||||
- **Quick Menu**: In-reader quick access menu for common actions (Dictionary, Bookmark, Clear Cache, Settings) via short power button press
|
||||
- **Library Search**: Search across all books by title, author, or filename with dynamic character picker and weighted relevance scoring
|
||||
- **CSS Support**: Parse and apply CSS styles from EPUB stylesheets (text-align, font-style, font-weight, text-decoration, margins, padding)
|
||||
- **Inline Image Support**: PNG and Baseline JPEG rendering within EPUB content with 2-bit grayscale dithering and caching
|
||||
- **Custom Fonts**: Atkinson Hyperlegible Next (low-vision readers) and Fern Micro (small screens)
|
||||
- **Enhanced Web Server**: File management (upload, download, delete, rename, copy, move, mkdir), companion app API, WebSocket uploads, mDNS discovery at `crosspoint.local`
|
||||
- **Reading Lists**: Create, manage, and pin custom book lists with web API support (CSV format)
|
||||
- **Enhanced Tab Bar**: Unified tab bar with horizontal scrolling and overflow indicators (Recent, Lists, Bookmarks, Search, Files)
|
||||
- **Progress Bar Status**: Additional status bar option showing visual reading progress
|
||||
- **OPDS Browser Enhancements**: Navigation history, page skipping (hold Up/Down), error retry, HTTP Basic Auth support
|
||||
|
||||
### Display Enhancements
|
||||
|
||||
- **High Contrast Mode**: System-wide contrast adjustment
|
||||
- **Bezel Compensation**: Configurable margin (0-10px) for physical screen edge defects
|
||||
- **Sleep Screen Improvements**: Edge-aware color filling for seamless letterbox appearance
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed device hanging when booted without USB connected (Serial.available()/Serial.read() called without Serial.begin())
|
||||
- Fixed grayscale state corruption causing ghosting artifacts when anti-aliasing enabled under memory pressure
|
||||
- Memory optimization with graceful degradation when memory is low
|
||||
|
||||
### Development Tools
|
||||
|
||||
- `pre_flash.py`: Displays "Flashing firmware..." screen during upload
|
||||
- `debugging_monitor.py`: Enhanced serial monitor with memory graphs
|
||||
- `pio_helper.py`: Interactive PlatformIO workflow helper
|
||||
|
||||
---
|
||||
|
||||
## Differences from Upstream 0.16.0
|
||||
|
||||
This fork is based on upstream 0.15.0. The following 0.16.0 features are not included:
|
||||
|
||||
- KOReader sync support
|
||||
- Non-English hyphenation patterns (Spanish, German, French, Russian)
|
||||
- XTC/XTCH file format support
|
||||
|
||||
See [crosspoint-ef-features.md](docs/crosspoint-ef-features.md) for complete feature documentation.
|
||||
@@ -68,7 +68,9 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
// Apply fixed transforms before any per-line layout work.
|
||||
applyParagraphIndent();
|
||||
|
||||
const int pageWidth = viewportWidth;
|
||||
// Apply horizontal margin (for blockquotes, nested content, etc.)
|
||||
const int leftMargin = blockStyle.marginLeft;
|
||||
const int pageWidth = viewportWidth - leftMargin;
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
@@ -81,7 +83,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||
|
||||
for (size_t i = 0; i < lineCount; ++i) {
|
||||
extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine);
|
||||
extractLine(i, pageWidth, spaceWidth, leftMargin, wordWidths, lineBreakIndices, processLine);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,14 +283,9 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get iterators to target word and style.
|
||||
auto wordIt = words.begin();
|
||||
auto styleIt = wordStyles.begin();
|
||||
std::advance(wordIt, wordIndex);
|
||||
std::advance(styleIt, wordIndex);
|
||||
|
||||
const std::string& word = *wordIt;
|
||||
const auto style = *styleIt;
|
||||
// Direct index access for vectors (more efficient than iterator + advance)
|
||||
const std::string& word = words[wordIndex];
|
||||
const auto wordStyle = wordStyles[wordIndex];
|
||||
|
||||
// Collect candidate breakpoints (byte offsets and hyphen requirements).
|
||||
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
|
||||
@@ -308,7 +305,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
}
|
||||
|
||||
const bool needsHyphen = info.requiresInsertedHyphen;
|
||||
const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), style, needsHyphen);
|
||||
const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), wordStyle, needsHyphen);
|
||||
if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) {
|
||||
continue; // Skip if too wide or not an improvement
|
||||
}
|
||||
@@ -325,25 +322,23 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
|
||||
// Split the word at the selected breakpoint and append a hyphen if required.
|
||||
std::string remainder = word.substr(chosenOffset);
|
||||
wordIt->resize(chosenOffset);
|
||||
words[wordIndex].resize(chosenOffset);
|
||||
if (chosenNeedsHyphen) {
|
||||
wordIt->push_back('-');
|
||||
words[wordIndex].push_back('-');
|
||||
}
|
||||
|
||||
// Insert the remainder word (with matching style) directly after the prefix.
|
||||
auto insertWordIt = std::next(wordIt);
|
||||
auto insertStyleIt = std::next(styleIt);
|
||||
words.insert(insertWordIt, remainder);
|
||||
wordStyles.insert(insertStyleIt, style);
|
||||
words.insert(words.begin() + wordIndex + 1, remainder);
|
||||
wordStyles.insert(wordStyles.begin() + wordIndex + 1, wordStyle);
|
||||
|
||||
// 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);
|
||||
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, wordStyle);
|
||||
wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
|
||||
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, const int leftMargin,
|
||||
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
|
||||
const size_t lineBreak = lineBreakIndices[breakIndex];
|
||||
@@ -366,37 +361,39 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
spacing = spareSpace / (lineWordCount - 1);
|
||||
}
|
||||
|
||||
// Calculate initial x position
|
||||
uint16_t xpos = 0;
|
||||
// Calculate initial x position (offset by left margin for blockquotes, etc.)
|
||||
uint16_t xpos = static_cast<uint16_t>(leftMargin);
|
||||
if (style == TextBlock::RIGHT_ALIGN) {
|
||||
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
|
||||
xpos += spareSpace - (lineWordCount - 1) * spaceWidth;
|
||||
} else if (style == TextBlock::CENTER_ALIGN) {
|
||||
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
|
||||
xpos += (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
|
||||
}
|
||||
|
||||
// Pre-calculate X positions for words
|
||||
std::list<uint16_t> lineXPos;
|
||||
std::vector<uint16_t> lineXPos;
|
||||
lineXPos.reserve(lineWordCount);
|
||||
for (size_t i = lastBreakAt; i < lineBreak; i++) {
|
||||
const uint16_t currentWordWidth = wordWidths[i];
|
||||
lineXPos.push_back(xpos);
|
||||
xpos += currentWordWidth + spacing;
|
||||
}
|
||||
|
||||
// Iterators always start at the beginning as we are moving content with splice below
|
||||
auto wordEndIt = words.begin();
|
||||
auto wordStyleEndIt = wordStyles.begin();
|
||||
auto wordUnderlineEndIt = wordUnderlines.begin();
|
||||
std::advance(wordEndIt, lineWordCount);
|
||||
std::advance(wordStyleEndIt, lineWordCount);
|
||||
std::advance(wordUnderlineEndIt, lineWordCount);
|
||||
// *** CRITICAL STEP: CONSUME DATA USING MOVE + ERASE ***
|
||||
// Move first lineWordCount elements from words into lineWords
|
||||
std::vector<std::string> lineWords(
|
||||
std::make_move_iterator(words.begin()),
|
||||
std::make_move_iterator(words.begin() + lineWordCount));
|
||||
words.erase(words.begin(), words.begin() + 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);
|
||||
std::list<bool> lineWordUnderlines;
|
||||
lineWordUnderlines.splice(lineWordUnderlines.begin(), wordUnderlines, wordUnderlines.begin(), wordUnderlineEndIt);
|
||||
std::vector<EpdFontFamily::Style> lineWordStyles(
|
||||
std::make_move_iterator(wordStyles.begin()),
|
||||
std::make_move_iterator(wordStyles.begin() + lineWordCount));
|
||||
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + lineWordCount);
|
||||
|
||||
std::vector<bool> lineWordUnderlines(
|
||||
wordUnderlines.begin(),
|
||||
wordUnderlines.begin() + lineWordCount);
|
||||
wordUnderlines.erase(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
|
||||
|
||||
for (auto& word : lineWords) {
|
||||
if (containsSoftHyphen(word)) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <functional>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -14,9 +13,9 @@
|
||||
class GfxRenderer;
|
||||
|
||||
class ParsedText {
|
||||
std::list<std::string> words;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
std::list<bool> wordUnderlines; // Track underline per word
|
||||
std::vector<std::string> words;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<bool> wordUnderlines; // Track underline per word
|
||||
TextBlock::Style style;
|
||||
BlockStyle blockStyle;
|
||||
bool extraParagraphSpacing;
|
||||
@@ -29,8 +28,8 @@ 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,
|
||||
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, int leftMargin,
|
||||
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
// Version 12: Added content offsets to LUT for position restoration after re-indexing
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 12;
|
||||
// Version 13: Added marginLeft and hasLeftBorder to BlockStyle serialization
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 13;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) +
|
||||
sizeof(uint32_t);
|
||||
|
||||
@@ -13,5 +13,7 @@ struct BlockStyle {
|
||||
int8_t marginBottom = 0; // 0-2 lines
|
||||
int8_t paddingTop = 0; // 0-2 lines (treated same as margin)
|
||||
int8_t paddingBottom = 0; // 0-2 lines (treated same as margin)
|
||||
int16_t textIndent = 0; // pixels
|
||||
int16_t textIndent = 0; // pixels (first line indent)
|
||||
int16_t marginLeft = 0; // pixels (horizontal indent for entire block)
|
||||
bool hasLeftBorder = false; // draw vertical bar in left margin (for blockquotes)
|
||||
};
|
||||
|
||||
@@ -11,6 +11,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw left border (vertical bar) for blockquotes
|
||||
if (blockStyle.hasLeftBorder && blockStyle.marginLeft > 0) {
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
const int barX = x + 4; // Small offset from left edge
|
||||
const int barTop = y;
|
||||
const int barBottom = y + lineHeight;
|
||||
// Draw a 2-pixel wide vertical bar
|
||||
renderer.drawLine(barX, barTop, barX, barBottom, true);
|
||||
renderer.drawLine(barX + 1, barTop, barX + 1, barBottom, true);
|
||||
}
|
||||
|
||||
auto wordIt = words.begin();
|
||||
auto wordStylesIt = wordStyles.begin();
|
||||
auto wordXposIt = wordXpos.begin();
|
||||
@@ -92,29 +103,31 @@ bool TextBlock::serialize(FsFile& file) const {
|
||||
serialization::writePod(file, blockStyle.paddingTop);
|
||||
serialization::writePod(file, blockStyle.paddingBottom);
|
||||
serialization::writePod(file, blockStyle.textIndent);
|
||||
serialization::writePod(file, blockStyle.marginLeft);
|
||||
serialization::writePod(file, blockStyle.hasLeftBorder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||
uint16_t wc;
|
||||
std::list<std::string> words;
|
||||
std::list<uint16_t> wordXpos;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
std::list<bool> wordUnderlines;
|
||||
std::vector<std::string> words;
|
||||
std::vector<uint16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<bool> wordUnderlines;
|
||||
Style style;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
// Word count
|
||||
serialization::readPod(file, wc);
|
||||
|
||||
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
|
||||
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
||||
if (wc > 10000) {
|
||||
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Word data
|
||||
// Word data - reserve capacity then resize
|
||||
words.resize(wc);
|
||||
wordXpos.resize(wc);
|
||||
wordStyles.resize(wc);
|
||||
@@ -124,14 +137,14 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||
|
||||
// Underline flags (packed as bytes, 8 words per byte)
|
||||
wordUnderlines.resize(wc, false);
|
||||
auto underlineIt = wordUnderlines.begin();
|
||||
size_t underlineIdx = 0;
|
||||
const int bytesNeeded = (wc + 7) / 8;
|
||||
for (int byteIdx = 0; byteIdx < bytesNeeded; byteIdx++) {
|
||||
uint8_t underlineByte;
|
||||
serialization::readPod(file, underlineByte);
|
||||
for (int bit = 0; bit < 8 && underlineIt != wordUnderlines.end(); bit++) {
|
||||
*underlineIt = (underlineByte & 1 << bit) != 0;
|
||||
++underlineIt;
|
||||
for (int bit = 0; bit < 8 && underlineIdx < wc; bit++) {
|
||||
wordUnderlines[underlineIdx] = (underlineByte & (1 << bit)) != 0;
|
||||
++underlineIdx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +157,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||
serialization::readPod(file, blockStyle.paddingTop);
|
||||
serialization::readPod(file, blockStyle.paddingBottom);
|
||||
serialization::readPod(file, blockStyle.textIndent);
|
||||
serialization::readPod(file, blockStyle.marginLeft);
|
||||
serialization::readPod(file, blockStyle.hasLeftBorder);
|
||||
|
||||
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style,
|
||||
blockStyle, std::move(wordUnderlines)));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#include <EpdFontFamily.h>
|
||||
#include <SdFat.h>
|
||||
|
||||
#include <list>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -20,17 +20,17 @@ class TextBlock final : public Block {
|
||||
};
|
||||
|
||||
private:
|
||||
std::list<std::string> words;
|
||||
std::list<uint16_t> wordXpos;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
std::list<bool> wordUnderlines; // Track underline per word
|
||||
std::vector<std::string> words;
|
||||
std::vector<uint16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<bool> wordUnderlines; // Track underline per word
|
||||
Style style;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
public:
|
||||
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
||||
std::list<EpdFontFamily::Style> word_styles, const Style style,
|
||||
const BlockStyle& blockStyle = BlockStyle(), std::list<bool> word_underlines = std::list<bool>())
|
||||
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
|
||||
std::vector<EpdFontFamily::Style> word_styles, const Style style,
|
||||
const BlockStyle& blockStyle = BlockStyle(), std::vector<bool> word_underlines = std::vector<bool>())
|
||||
: words(std::move(words)),
|
||||
wordXpos(std::move(word_xpos)),
|
||||
wordStyles(std::move(word_styles)),
|
||||
@@ -50,9 +50,9 @@ class TextBlock final : public Block {
|
||||
bool isEmpty() override { return words.empty(); }
|
||||
|
||||
// Getters for word selection support
|
||||
const std::list<std::string>& getWords() const { return words; }
|
||||
const std::list<uint16_t>& getWordXPositions() const { return wordXpos; }
|
||||
const std::list<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
||||
const std::vector<std::string>& getWords() const { return words; }
|
||||
const std::vector<uint16_t>& getWordXPositions() const { return wordXpos; }
|
||||
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
||||
size_t getWordCount() const { return words.size(); }
|
||||
void layout(GfxRenderer& renderer) override {};
|
||||
// given a renderer works out where to break the words into lines
|
||||
|
||||
@@ -393,6 +393,32 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||
style.paddingBottom = spacing;
|
||||
style.defined.paddingBottom = 1;
|
||||
}
|
||||
} else if (propName == "margin-left" || propName == "padding-left") {
|
||||
// Horizontal indentation for blockquotes and nested content
|
||||
const float pixels = interpretLength(propValue);
|
||||
if (pixels > 0) {
|
||||
style.marginLeft += pixels; // Accumulate margin-left and padding-left
|
||||
style.defined.marginLeft = 1;
|
||||
}
|
||||
} else if (propName == "margin") {
|
||||
// Shorthand: margin: top right bottom left OR margin: vertical horizontal
|
||||
const auto values = splitWhitespace(propValue);
|
||||
if (values.size() >= 2) {
|
||||
// At least 2 values: first is vertical (top/bottom), second is horizontal (left/right)
|
||||
const float horizontal = interpretLength(values[1]);
|
||||
if (horizontal > 0) {
|
||||
style.marginLeft = horizontal;
|
||||
style.defined.marginLeft = 1;
|
||||
}
|
||||
}
|
||||
if (values.size() == 4) {
|
||||
// 4 values: top right bottom left - use the 4th value for left
|
||||
const float left = interpretLength(values[3]);
|
||||
if (left > 0) {
|
||||
style.marginLeft = left;
|
||||
style.defined.marginLeft = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ struct CssPropertyFlags {
|
||||
uint16_t marginBottom : 1;
|
||||
uint16_t paddingTop : 1;
|
||||
uint16_t paddingBottom : 1;
|
||||
uint16_t reserved : 7;
|
||||
uint16_t marginLeft : 1;
|
||||
uint16_t reserved : 6;
|
||||
|
||||
CssPropertyFlags()
|
||||
: alignment(0),
|
||||
@@ -37,16 +38,17 @@ struct CssPropertyFlags {
|
||||
marginBottom(0),
|
||||
paddingTop(0),
|
||||
paddingBottom(0),
|
||||
marginLeft(0),
|
||||
reserved(0) {}
|
||||
|
||||
[[nodiscard]] bool anySet() const {
|
||||
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || paddingTop ||
|
||||
paddingBottom;
|
||||
paddingBottom || marginLeft;
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
alignment = fontStyle = fontWeight = decoration = indent = 0;
|
||||
marginTop = marginBottom = paddingTop = paddingBottom = 0;
|
||||
marginTop = marginBottom = paddingTop = paddingBottom = marginLeft = 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,6 +65,7 @@ struct CssStyle {
|
||||
int8_t marginBottom = 0; // Vertical spacing after block (in lines, 0-2)
|
||||
int8_t paddingTop = 0; // Padding before (in lines, 0-2)
|
||||
int8_t paddingBottom = 0; // Padding after (in lines, 0-2)
|
||||
float marginLeft = 0.0f; // Horizontal indent in pixels (for blockquotes, etc.)
|
||||
|
||||
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
||||
|
||||
@@ -105,6 +108,10 @@ struct CssStyle {
|
||||
paddingBottom = base.paddingBottom;
|
||||
defined.paddingBottom = 1;
|
||||
}
|
||||
if (base.defined.marginLeft) {
|
||||
marginLeft = base.marginLeft;
|
||||
defined.marginLeft = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility accessors for existing code that uses hasX pattern
|
||||
@@ -117,6 +124,7 @@ struct CssStyle {
|
||||
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
|
||||
[[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; }
|
||||
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
|
||||
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
|
||||
|
||||
// Merge another style (alias for applyOver for compatibility)
|
||||
void merge(const CssStyle& other) { applyOver(other); }
|
||||
@@ -128,6 +136,7 @@ struct CssStyle {
|
||||
decoration = CssTextDecoration::None;
|
||||
indentPixels = 0.0f;
|
||||
marginTop = marginBottom = paddingTop = paddingBottom = 0;
|
||||
marginLeft = 0.0f;
|
||||
defined.clearAll();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,6 +19,9 @@ constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
||||
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
||||
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
|
||||
|
||||
const char* LIST_TAGS[] = {"ol", "ul"};
|
||||
constexpr int NUM_LIST_TAGS = sizeof(LIST_TAGS) / sizeof(LIST_TAGS[0]);
|
||||
|
||||
const char* BOLD_TAGS[] = {"b", "strong"};
|
||||
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
|
||||
|
||||
@@ -55,6 +58,7 @@ BlockStyle createBlockStyleFromCss(const CssStyle& cssStyle) {
|
||||
blockStyle.paddingTop = cssStyle.paddingTop;
|
||||
blockStyle.paddingBottom = cssStyle.paddingBottom;
|
||||
blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels);
|
||||
blockStyle.marginLeft = static_cast<int16_t>(cssStyle.marginLeft);
|
||||
return blockStyle;
|
||||
}
|
||||
|
||||
@@ -320,6 +324,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
|
||||
// Determine if this is a block element
|
||||
bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
||||
bool isListTag = matches(name, LIST_TAGS, NUM_LIST_TAGS);
|
||||
|
||||
// Handle list container tags (ol, ul)
|
||||
if (isListTag) {
|
||||
ListContext ctx;
|
||||
ctx.isOrdered = strcmp(name, "ol") == 0;
|
||||
ctx.counter = 0;
|
||||
ctx.depth = self->depth;
|
||||
self->listStack.push_back(ctx);
|
||||
self->depth += 1;
|
||||
return; // Lists themselves don't create text blocks
|
||||
}
|
||||
|
||||
// Compute CSS style for this element
|
||||
CssStyle cssStyle;
|
||||
@@ -365,6 +381,20 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
// This fixes issue where <br/> incorrectly wrapped the preceding word to a new line
|
||||
self->flushPartWordBuffer();
|
||||
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
||||
} else if (strcmp(name, "li") == 0) {
|
||||
// For list items, DON'T create a text block yet - wait for the first content element
|
||||
// This prevents the marker from being on its own line when <li><p>content</p></li>
|
||||
self->insideListItem = true;
|
||||
self->listItemDepth = self->depth;
|
||||
self->listItemHasContent = false;
|
||||
|
||||
// Increment counter now (so nested lists work correctly)
|
||||
if (!self->listStack.empty()) {
|
||||
self->listStack.back().counter++;
|
||||
}
|
||||
// Don't create text block or add marker yet - will be done when first content arrives
|
||||
self->depth += 1;
|
||||
return;
|
||||
} else {
|
||||
// Determine alignment from CSS or default
|
||||
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
|
||||
@@ -387,12 +417,71 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default styling for blockquote if no CSS margin is specified
|
||||
const bool isBlockquote = strcmp(name, "blockquote") == 0;
|
||||
if (isBlockquote) {
|
||||
if (!cssStyle.hasMarginLeft()) {
|
||||
// Default left indent for blockquotes (~1.5em at 16px base = 24px)
|
||||
cssStyle.marginLeft = 24.0f;
|
||||
cssStyle.defined.marginLeft = 1;
|
||||
}
|
||||
// Also make blockquotes italic by default if not specified
|
||||
if (!cssStyle.hasFontStyle()) {
|
||||
cssStyle.fontStyle = CssFontStyle::Italic;
|
||||
cssStyle.defined.fontStyle = 1;
|
||||
}
|
||||
// Track blockquote context for child elements
|
||||
self->insideBlockquote = true;
|
||||
self->blockquoteDepth = self->depth;
|
||||
self->blockquoteMarginLeft = cssStyle.marginLeft;
|
||||
}
|
||||
|
||||
// Apply blockquote styling to child block elements
|
||||
if (self->insideBlockquote && !isBlockquote) {
|
||||
// Inherit margin and border from parent blockquote
|
||||
if (!cssStyle.hasMarginLeft()) {
|
||||
cssStyle.marginLeft = self->blockquoteMarginLeft;
|
||||
cssStyle.defined.marginLeft = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply left margin to list items (indent the whole block)
|
||||
if (self->insideListItem && !cssStyle.hasMarginLeft()) {
|
||||
// Default left indent for list items (~1.5em at 16px base = 24px)
|
||||
cssStyle.marginLeft = 24.0f;
|
||||
cssStyle.defined.marginLeft = 1;
|
||||
}
|
||||
|
||||
self->currentBlockStyle = cssStyle;
|
||||
self->startNewTextBlock(alignment, createBlockStyleFromCss(cssStyle));
|
||||
BlockStyle blockStyleForElement = createBlockStyleFromCss(cssStyle);
|
||||
if (isBlockquote || self->insideBlockquote) {
|
||||
blockStyleForElement.hasLeftBorder = true; // Draw vertical bar for blockquotes
|
||||
}
|
||||
self->startNewTextBlock(alignment, blockStyleForElement);
|
||||
self->updateEffectiveInlineStyle();
|
||||
|
||||
if (strcmp(name, "li") == 0) {
|
||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||
// If this is a blockquote, apply italic styling
|
||||
if (isBlockquote && cssStyle.hasFontStyle() && cssStyle.fontStyle == CssFontStyle::Italic) {
|
||||
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||
}
|
||||
|
||||
// If this is the first block element inside a list item, add the marker
|
||||
if (self->insideListItem && !self->listItemHasContent) {
|
||||
if (!self->listStack.empty()) {
|
||||
const ListContext& ctx = self->listStack.back();
|
||||
if (ctx.isOrdered) {
|
||||
// Ordered list: use number (counter was already incremented)
|
||||
std::string marker = std::to_string(ctx.counter) + ". ";
|
||||
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
|
||||
} else {
|
||||
// Unordered list: use bullet
|
||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||
}
|
||||
} else {
|
||||
// No list context (orphan li), use bullet as fallback
|
||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||
}
|
||||
self->listItemHasContent = true;
|
||||
}
|
||||
}
|
||||
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
|
||||
@@ -566,7 +655,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
const bool shouldFlush = styleWillChange || 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) ||
|
||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || self->depth == 1;
|
||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) ||
|
||||
matches(name, LIST_TAGS, NUM_LIST_TAGS) || self->depth == 1;
|
||||
|
||||
if (shouldFlush) {
|
||||
// Use combined depth-based and CSS-based style
|
||||
@@ -596,6 +686,27 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
self->skipUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Leaving list container (ol, ul)
|
||||
if (matches(name, LIST_TAGS, NUM_LIST_TAGS)) {
|
||||
if (!self->listStack.empty() && self->listStack.back().depth == self->depth) {
|
||||
self->listStack.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving list item (li)
|
||||
if (strcmp(name, "li") == 0 && self->listItemDepth == self->depth) {
|
||||
self->insideListItem = false;
|
||||
self->listItemDepth = INT_MAX;
|
||||
self->listItemHasContent = false;
|
||||
}
|
||||
|
||||
// Leaving blockquote
|
||||
if (strcmp(name, "blockquote") == 0 && self->blockquoteDepth == self->depth) {
|
||||
self->insideBlockquote = false;
|
||||
self->blockquoteDepth = INT_MAX;
|
||||
self->blockquoteMarginLeft = 0.0f;
|
||||
}
|
||||
|
||||
// Leaving bold tag
|
||||
if (self->boldUntilDepth == self->depth) {
|
||||
self->boldUntilDepth = INT_MAX;
|
||||
|
||||
@@ -59,6 +59,22 @@ class ChapterHtmlSlimParser {
|
||||
bool effectiveItalic = false;
|
||||
bool effectiveUnderline = false;
|
||||
|
||||
// List context tracking for ordered/unordered lists
|
||||
struct ListContext {
|
||||
bool isOrdered = false; // true for <ol>, false for <ul>
|
||||
int counter = 0; // Current item number (for ordered lists)
|
||||
int depth = 0; // Depth at which list was opened
|
||||
};
|
||||
std::vector<ListContext> listStack;
|
||||
bool insideListItem = false; // True when we're inside an <li> element
|
||||
int listItemDepth = INT_MAX; // Depth at which <li> was opened
|
||||
bool listItemHasContent = false; // True if we've added content to the current list item
|
||||
|
||||
// Blockquote context tracking (for left border on child elements)
|
||||
bool insideBlockquote = false;
|
||||
int blockquoteDepth = INT_MAX;
|
||||
float blockquoteMarginLeft = 0.0f; // Inherit margin from blockquote to child elements
|
||||
|
||||
// Byte offset tracking for position restoration after re-indexing
|
||||
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
|
||||
size_t currentPageStartOffset = 0; // Byte offset when current page was started
|
||||
|
||||
@@ -650,7 +650,8 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
|
||||
setOrientation(orig_orientation);
|
||||
}
|
||||
|
||||
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) {
|
||||
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn,
|
||||
const bool drawBorder) {
|
||||
const Orientation orig_orientation = getOrientation();
|
||||
setOrientation(Orientation::Portrait);
|
||||
|
||||
@@ -671,27 +672,32 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
|
||||
// Draw the shared border for both buttons as one unit
|
||||
const int x = screenWidth - buttonX - buttonWidth;
|
||||
|
||||
// Draw top button outline (3 sides, bottom open)
|
||||
if (topBtn != nullptr && topBtn[0] != '\0') {
|
||||
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
||||
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
|
||||
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
|
||||
}
|
||||
if (drawBorder) {
|
||||
// Draw top button outline (3 sides, bottom open)
|
||||
if (topBtn != nullptr && topBtn[0] != '\0') {
|
||||
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
||||
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
|
||||
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
|
||||
}
|
||||
|
||||
// Draw shared middle border
|
||||
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
|
||||
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
|
||||
}
|
||||
// Draw shared middle border
|
||||
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
|
||||
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
|
||||
}
|
||||
|
||||
// Draw bottom button outline (3 sides, top is shared)
|
||||
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
||||
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
|
||||
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
|
||||
topButtonY + 2 * buttonHeight - 1); // Right
|
||||
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
|
||||
// Draw bottom button outline (3 sides, top is shared)
|
||||
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
||||
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
|
||||
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
|
||||
topButtonY + 2 * buttonHeight - 1); // Right
|
||||
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
|
||||
}
|
||||
}
|
||||
|
||||
// Draw text for each button
|
||||
// Use CCW rotation for LandscapeCCW so text reads in same direction as screen content
|
||||
const bool useCCW = (orig_orientation == Orientation::LandscapeCounterClockwise);
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
||||
const int y = topButtonY + i * buttonHeight;
|
||||
@@ -700,11 +706,22 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
|
||||
const int textWidth = getTextWidth(fontId, labels[i]);
|
||||
const int textHeight = getTextHeight(fontId);
|
||||
|
||||
// Center the rotated text in the button
|
||||
const int textX = x + (buttonWidth - textHeight) / 2;
|
||||
const int textY = y + (buttonHeight + textWidth) / 2;
|
||||
int textX, textY;
|
||||
if (drawBorder) {
|
||||
// Center the rotated text in the button
|
||||
textX = x + (buttonWidth - textHeight) / 2;
|
||||
textY = useCCW ? y + (buttonHeight - textWidth) / 2 : y + (buttonHeight + textWidth) / 2;
|
||||
} else {
|
||||
// Position at edge with 2px margin (no border mode)
|
||||
textX = screenWidth - bezelRight - textHeight - 2;
|
||||
textY = useCCW ? y + (buttonHeight - textWidth) / 2 : y + (buttonHeight + textWidth) / 2;
|
||||
}
|
||||
|
||||
drawTextRotated90CW(fontId, textX, textY, labels[i]);
|
||||
if (useCCW) {
|
||||
drawTextRotated90CCW(fontId, textX, textY, labels[i]);
|
||||
} else {
|
||||
drawTextRotated90CW(fontId, textX, textY, labels[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,6 +819,89 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
// Cannot draw a NULL / empty string
|
||||
if (text == nullptr || *text == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fontMap.count(fontId) == 0) {
|
||||
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||
return;
|
||||
}
|
||||
const auto font = fontMap.at(fontId);
|
||||
|
||||
// No printable characters
|
||||
if (!font.hasPrintableChars(text, style)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For 90° counter-clockwise rotation:
|
||||
// Original (glyphX, glyphY) -> Rotated (-glyphY, glyphX)
|
||||
// Text reads from top to bottom
|
||||
|
||||
int yPos = y; // Current Y position (increases as we draw characters)
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
if (!glyph) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int is2Bit = font.getData(style)->is2Bit;
|
||||
const uint32_t offset = glyph->dataOffset;
|
||||
const uint8_t width = glyph->width;
|
||||
const uint8_t height = glyph->height;
|
||||
const int left = glyph->left;
|
||||
const int top = glyph->top;
|
||||
|
||||
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||
const int pixelPosition = glyphY * width + glyphX;
|
||||
|
||||
// 90° counter-clockwise rotation transformation:
|
||||
// screenX = x + (top - glyphY)
|
||||
// screenY = yPos + (left + glyphX)
|
||||
const int screenX = x + (top - glyphY);
|
||||
const int screenY = yPos + left + glyphX;
|
||||
|
||||
if (is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
}
|
||||
} else {
|
||||
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||
|
||||
if ((byte >> bit_index) & 1) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next character position (going down, so increase Y)
|
||||
yPos += glyph->advanceX;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||
|
||||
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
|
||||
|
||||
@@ -116,12 +116,15 @@ class GfxRenderer {
|
||||
|
||||
// UI Components
|
||||
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4);
|
||||
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn);
|
||||
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn, bool drawBorder = true);
|
||||
|
||||
private:
|
||||
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
|
||||
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
// Helper for drawing rotated text (90 degrees counter-clockwise, for LandscapeCCW orientation)
|
||||
void drawTextRotated90CCW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
int getTextHeight(int fontId) const;
|
||||
|
||||
public:
|
||||
|
||||
@@ -205,6 +205,19 @@ bool StarDict::loadDictzipHeader() {
|
||||
|
||||
bool StarDict::begin() {
|
||||
if (!loadInfo()) return false;
|
||||
|
||||
// Try uncompressed .dict file first (preferred - no memory overhead)
|
||||
const std::string dictPath = basePath + ".dict";
|
||||
FsFile testFile;
|
||||
if (SdMan.openFileForRead("DICT", dictPath, testFile)) {
|
||||
testFile.close();
|
||||
useUncompressed = true;
|
||||
Serial.printf("[%lu] [DICT] Using uncompressed .dict file (no decompression needed)\n", millis());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to compressed .dict.dz
|
||||
useUncompressed = false;
|
||||
if (!loadDictzipHeader()) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -238,12 +251,46 @@ bool StarDict::readWordAtPosition(FsFile& idxFile, uint32_t& position, std::stri
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StarDict::readDefinitionDirect(uint32_t offset, uint32_t size, std::string& definition) {
|
||||
// Read directly from uncompressed .dict file - no decompression needed!
|
||||
const std::string dictPath = basePath + ".dict";
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("DICT", dictPath, file)) {
|
||||
Serial.printf("[DICT-DBG] Failed to open .dict file\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Seek to the definition offset
|
||||
if (!file.seek(offset)) {
|
||||
Serial.printf("[DICT-DBG] Failed to seek to offset %lu\n", offset);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read the definition directly into the string
|
||||
definition.resize(size);
|
||||
const int bytesRead = file.read(&definition[0], size);
|
||||
file.close();
|
||||
|
||||
if (bytesRead != static_cast<int>(size)) {
|
||||
Serial.printf("[DICT-DBG] Read %d bytes, expected %lu\n", bytesRead, size);
|
||||
definition.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string& definition) {
|
||||
if (!dzInfo.loaded) return false;
|
||||
if (!dzInfo.loaded) {
|
||||
Serial.printf("[DICT-DBG] dzInfo not loaded!\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string dzPath = basePath + ".dict.dz";
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("DICT", dzPath, file)) {
|
||||
Serial.printf("[DICT-DBG] Failed to open dict.dz file\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -252,7 +299,11 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
||||
const uint32_t endChunk = (offset + size - 1) / dzInfo.chunkLength;
|
||||
const uint32_t startOffsetInChunk = offset % dzInfo.chunkLength;
|
||||
|
||||
Serial.printf("[DICT-DBG] Chunks: start=%lu, end=%lu, total=%u\n",
|
||||
startChunk, endChunk, dzInfo.chunkCount);
|
||||
|
||||
if (endChunk >= dzInfo.chunkCount) {
|
||||
Serial.printf("[DICT-DBG] endChunk %lu >= chunkCount %u\n", endChunk, dzInfo.chunkCount);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
@@ -263,13 +314,38 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
||||
fileOffset += dzInfo.chunkSizes[i];
|
||||
}
|
||||
|
||||
// Allocate buffers
|
||||
const uint32_t maxCompressedSize = 65536; // Max compressed chunk size
|
||||
// Calculate actual max compressed size needed for the chunks we'll process
|
||||
uint32_t maxCompressedSize = 0;
|
||||
for (uint32_t i = startChunk; i <= endChunk; i++) {
|
||||
if (dzInfo.chunkSizes[i] > maxCompressedSize) {
|
||||
maxCompressedSize = dzInfo.chunkSizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate buffers - allocate inflator FIRST (smallest) to reduce fragmentation impact
|
||||
// tinfl_decompressor is ~11KB, so total allocations are ~85KB
|
||||
Serial.printf("[DICT-DBG] Allocating inflator=%u, comp=%lu, decomp=%u bytes\n",
|
||||
sizeof(tinfl_decompressor), maxCompressedSize, dzInfo.chunkLength);
|
||||
|
||||
auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||
if (!inflator) {
|
||||
Serial.printf("[DICT-DBG] inflator alloc failed! (need %u bytes)\n", sizeof(tinfl_decompressor));
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* compressedBuf = static_cast<uint8_t*>(malloc(maxCompressedSize));
|
||||
if (!compressedBuf) {
|
||||
Serial.printf("[DICT-DBG] compressedBuf alloc failed!\n");
|
||||
free(inflator);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
auto* decompressedBuf = static_cast<uint8_t*>(malloc(dzInfo.chunkLength));
|
||||
if (!compressedBuf || !decompressedBuf) {
|
||||
if (!decompressedBuf) {
|
||||
Serial.printf("[DICT-DBG] decompressedBuf alloc failed!\n");
|
||||
free(inflator);
|
||||
free(compressedBuf);
|
||||
free(decompressedBuf);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
@@ -277,13 +353,15 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
||||
definition.clear();
|
||||
definition.reserve(size);
|
||||
|
||||
// Process each needed chunk
|
||||
// Process each needed chunk (reusing inflator allocation)
|
||||
for (uint32_t chunk = startChunk; chunk <= endChunk; chunk++) {
|
||||
const uint16_t compressedSize = dzInfo.chunkSizes[chunk];
|
||||
|
||||
// Seek and read compressed data
|
||||
file.seek(fileOffset);
|
||||
if (file.read(compressedBuf, compressedSize) != compressedSize) {
|
||||
Serial.printf("[DICT-DBG] File read failed at offset %lu, size %u\n", fileOffset, compressedSize);
|
||||
free(inflator);
|
||||
free(compressedBuf);
|
||||
free(decompressedBuf);
|
||||
file.close();
|
||||
@@ -291,13 +369,6 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
||||
}
|
||||
|
||||
// Decompress using raw inflate (no zlib header)
|
||||
auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||
if (!inflator) {
|
||||
free(compressedBuf);
|
||||
free(decompressedBuf);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
tinfl_init(inflator);
|
||||
|
||||
size_t inBytes = compressedSize;
|
||||
@@ -306,19 +377,13 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
||||
tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
|
||||
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | TINFL_FLAG_PARSE_ZLIB_HEADER);
|
||||
|
||||
free(inflator);
|
||||
|
||||
if (status != TINFL_STATUS_DONE && status != TINFL_STATUS_HAS_MORE_OUTPUT) {
|
||||
// Try without zlib header flag
|
||||
inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||
if (inflator) {
|
||||
tinfl_init(inflator);
|
||||
inBytes = compressedSize;
|
||||
outBytes = dzInfo.chunkLength;
|
||||
tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
|
||||
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
|
||||
free(inflator);
|
||||
}
|
||||
tinfl_init(inflator);
|
||||
inBytes = compressedSize;
|
||||
outBytes = dzInfo.chunkLength;
|
||||
tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
|
||||
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
|
||||
}
|
||||
|
||||
// Extract the portion we need from this chunk
|
||||
@@ -342,6 +407,7 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
||||
fileOffset += compressedSize;
|
||||
}
|
||||
|
||||
free(inflator);
|
||||
free(compressedBuf);
|
||||
free(decompressedBuf);
|
||||
file.close();
|
||||
@@ -349,9 +415,9 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
||||
return true;
|
||||
}
|
||||
|
||||
// StarDict comparison function: case-insensitive first, then case-sensitive as tiebreaker
|
||||
// StarDict comparison function: case-insensitive matching
|
||||
int StarDict::stardictStrcmp(const std::string& a, const std::string& b) {
|
||||
// First: case-insensitive comparison (like g_ascii_strcasecmp)
|
||||
// Case-insensitive comparison (like g_ascii_strcasecmp)
|
||||
size_t i = 0;
|
||||
while (i < a.length() && i < b.length()) {
|
||||
const int ca = std::tolower(static_cast<unsigned char>(a[i]));
|
||||
@@ -362,8 +428,8 @@ int StarDict::stardictStrcmp(const std::string& a, const std::string& b) {
|
||||
if (a.length() != b.length()) {
|
||||
return static_cast<int>(a.length()) - static_cast<int>(b.length());
|
||||
}
|
||||
// If case-insensitive equal, use case-sensitive as tiebreaker
|
||||
return a.compare(b);
|
||||
// Case-insensitive match found
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string StarDict::normalizeWord(const std::string& word) {
|
||||
@@ -403,6 +469,9 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Serial.printf("[DICT-DBG] Searching for: '%s' (normalized: '%s')\n",
|
||||
word.c_str(), normalizedSearch.c_str());
|
||||
|
||||
// First try .idx (main entries) - use prefix jump table for fast lookup
|
||||
const std::string idxPath = basePath + ".idx";
|
||||
FsFile idxFile;
|
||||
@@ -418,7 +487,10 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
||||
const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]);
|
||||
position = DictPrefixIndex::dictPrefixOffsets[prefixIdx];
|
||||
}
|
||||
Serial.printf("[DICT-DBG] Starting at position %lu (prefix: %c%c)\n",
|
||||
position, normalizedSearch[0], normalizedSearch[1]);
|
||||
bool found = false;
|
||||
uint32_t wordCount = 0;
|
||||
|
||||
while (position < info.idxfilesize) {
|
||||
std::string currentWord;
|
||||
@@ -427,13 +499,24 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
||||
if (!readWordAtPosition(idxFile, position, currentWord, dictOffset, dictSize)) {
|
||||
break;
|
||||
}
|
||||
wordCount++;
|
||||
if (wordCount % 50000 == 0) {
|
||||
Serial.printf("[DICT-DBG] Progress: %lu words scanned, pos=%lu, current='%s'\n",
|
||||
wordCount, position, currentWord.c_str());
|
||||
}
|
||||
|
||||
// Use stardictStrcmp for case-insensitive matching
|
||||
const int cmp = stardictStrcmp(normalizedSearch, currentWord);
|
||||
|
||||
if (cmp == 0) {
|
||||
Serial.printf("[DICT-DBG] MATCH: '%s' == '%s' (offset=%lu, size=%lu)\n",
|
||||
normalizedSearch.c_str(), currentWord.c_str(), dictOffset, dictSize);
|
||||
std::string definition;
|
||||
if (decompressDefinition(dictOffset, dictSize, definition)) {
|
||||
const bool loaded = useUncompressed
|
||||
? readDefinitionDirect(dictOffset, dictSize, definition)
|
||||
: decompressDefinition(dictOffset, dictSize, definition);
|
||||
if (loaded) {
|
||||
Serial.printf("[DICT-DBG] Definition loaded, %u bytes\n", definition.length());
|
||||
if (!found) {
|
||||
result.word = currentWord;
|
||||
result.definition = definition;
|
||||
@@ -442,14 +525,20 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
||||
} else {
|
||||
result.definition += "</html>" + definition;
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[DICT-DBG] Definition load FAILED!\n");
|
||||
}
|
||||
// Continue scanning for additional matches (same word, different case)
|
||||
} else if (cmp < 0) {
|
||||
// Passed where target would be (file is sorted)
|
||||
} else if (found) {
|
||||
// We had matches but now moved past them - safe to stop
|
||||
break;
|
||||
}
|
||||
// Note: Cannot use early-break before first match because prefix index
|
||||
// may not land exactly at target position
|
||||
}
|
||||
|
||||
Serial.printf("[DICT-DBG] Search complete: %lu words scanned, found=%s\n",
|
||||
wordCount, found ? "YES" : "NO");
|
||||
idxFile.close();
|
||||
|
||||
// If not found in main index, try synonym file with prefix jump
|
||||
@@ -502,7 +591,10 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
||||
uint32_t dictOffset, dictSize;
|
||||
if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) {
|
||||
std::string definition;
|
||||
if (decompressDefinition(dictOffset, dictSize, definition)) {
|
||||
const bool loaded = useUncompressed
|
||||
? readDefinitionDirect(dictOffset, dictSize, definition)
|
||||
: decompressDefinition(dictOffset, dictSize, definition);
|
||||
if (loaded) {
|
||||
result.word = synWord;
|
||||
result.definition = definition;
|
||||
result.found = true;
|
||||
@@ -513,10 +605,9 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
||||
idxFile2.close();
|
||||
}
|
||||
break; // Found a match, stop searching
|
||||
} else if (cmp < 0) {
|
||||
// Passed where it would be (file is sorted)
|
||||
break;
|
||||
}
|
||||
// Note: Cannot use early-break optimization here because prefix index
|
||||
// may not land exactly at target position
|
||||
}
|
||||
synFile.close();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#include <string>
|
||||
|
||||
// StarDict dictionary lookup library
|
||||
// Supports .ifo/.idx/.dict.dz format with linear scan lookup
|
||||
// Supports .ifo/.idx/.dict (uncompressed) and .ifo/.idx/.dict.dz (compressed) formats
|
||||
class StarDict {
|
||||
public:
|
||||
struct DictInfo {
|
||||
@@ -38,16 +38,22 @@ class StarDict {
|
||||
};
|
||||
DictzipInfo dzInfo;
|
||||
|
||||
// Whether to use uncompressed .dict file (preferred) or compressed .dict.dz
|
||||
bool useUncompressed = false;
|
||||
|
||||
// Parse .ifo file
|
||||
bool loadInfo();
|
||||
|
||||
// Load dictzip header for random access
|
||||
// Load dictzip header for random access (only if using compressed)
|
||||
bool loadDictzipHeader();
|
||||
|
||||
// Read word at given index file position, returns word and advances position
|
||||
bool readWordAtPosition(FsFile& idxFile, uint32_t& position, std::string& word, uint32_t& dictOffset,
|
||||
uint32_t& dictSize);
|
||||
|
||||
// Read definition directly from uncompressed .dict file (no decompression needed)
|
||||
bool readDefinitionDirect(uint32_t offset, uint32_t size, std::string& definition);
|
||||
|
||||
// Decompress a portion of the .dict.dz file
|
||||
bool decompressDefinition(uint32_t offset, uint32_t size, std::string& definition);
|
||||
|
||||
|
||||
@@ -529,10 +529,24 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
}
|
||||
|
||||
if (fileStat.method == MZ_DEFLATED) {
|
||||
// Setup inflator
|
||||
// Allocate largest buffer first to maximize chance of finding contiguous block
|
||||
// Dictionary buffer (32KB) - needed for DEFLATE sliding window
|
||||
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
|
||||
if (!outputBuffer) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary (need %d bytes)\n", millis(),
|
||||
TINFL_LZ_DICT_SIZE);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
|
||||
|
||||
// Setup inflator (~11KB)
|
||||
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||
if (!inflator) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis());
|
||||
free(outputBuffer);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -541,29 +555,18 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
memset(inflator, 0, sizeof(tinfl_decompressor));
|
||||
tinfl_init(inflator);
|
||||
|
||||
// Setup file read buffer
|
||||
// Setup file read buffer (smallest allocation last)
|
||||
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
|
||||
if (!fileReadBuffer) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis());
|
||||
free(inflator);
|
||||
free(outputBuffer);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
|
||||
if (!outputBuffer) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary\n", millis());
|
||||
free(inflator);
|
||||
free(fileReadBuffer);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
|
||||
|
||||
size_t fileRemainingBytes = deflatedDataSize;
|
||||
size_t processedOutputBytes = 0;
|
||||
size_t fileReadBufferFilledBytes = 0;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
default_envs = default
|
||||
|
||||
[crosspoint]
|
||||
version = ef-0.15.99
|
||||
# 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch
|
||||
version = 0.15.ef-1.0.4
|
||||
|
||||
[base]
|
||||
platform = espressif32 @ 6.12.0
|
||||
|
||||
335
scripts/recompress_dictzip.py
Normal file
335
scripts/recompress_dictzip.py
Normal file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recompress a dictzip file with a custom chunk size.
|
||||
|
||||
Dictzip is a gzip-compatible format that allows random access by compressing
|
||||
data in independent chunks. The standard dictzip uses ~58KB chunks, but this
|
||||
can cause memory issues on embedded devices like ESP32.
|
||||
|
||||
This script recompresses dictionary files with smaller chunks (default 16KB)
|
||||
to reduce memory requirements during decompression.
|
||||
|
||||
Usage:
|
||||
# From uncompressed .dict file:
|
||||
python recompress_dictzip.py reader.dict reader.dict.dz --chunk-size 16384
|
||||
|
||||
# From existing .dict.dz file (will decompress first):
|
||||
python recompress_dictzip.py reader.dict.dz reader_small.dict.dz --chunk-size 16384
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_input_file(input_path: Path) -> bytes:
|
||||
"""Read input file, decompressing if it's a .dz or .gz file."""
|
||||
suffix = input_path.suffix.lower()
|
||||
|
||||
if suffix in ('.dz', '.gz'):
|
||||
print(f"Decompressing {input_path}...")
|
||||
with gzip.open(input_path, 'rb') as f:
|
||||
data = f.read()
|
||||
print(f" Decompressed size: {len(data):,} bytes")
|
||||
return data
|
||||
else:
|
||||
print(f"Reading {input_path}...")
|
||||
with open(input_path, 'rb') as f:
|
||||
data = f.read()
|
||||
print(f" Size: {len(data):,} bytes")
|
||||
return data
|
||||
|
||||
|
||||
def compress_chunk(data: bytes, level: int = 9) -> bytes:
|
||||
"""Compress a single chunk using raw deflate (no zlib header)."""
|
||||
# Use raw deflate (-15 for raw, 15 for window size)
|
||||
compressor = zlib.compressobj(level, zlib.DEFLATED, -15)
|
||||
compressed = compressor.compress(data)
|
||||
compressed += compressor.flush()
|
||||
return compressed
|
||||
|
||||
|
||||
def create_dictzip(data: bytes, output_path: Path, chunk_size: int = 16384,
|
||||
compression_level: int = 9) -> None:
|
||||
"""
|
||||
Create a dictzip file from uncompressed data.
|
||||
|
||||
Dictzip format:
|
||||
- Standard gzip header with FEXTRA flag
|
||||
- Extra field containing 'RA' subfield with chunk info
|
||||
- Compressed chunks (raw deflate, no headers)
|
||||
- Standard gzip trailer (CRC32 + ISIZE)
|
||||
"""
|
||||
# Validate chunk size (must fit in 16-bit field)
|
||||
if chunk_size > 65535:
|
||||
raise ValueError(f"Chunk size {chunk_size} exceeds maximum of 65535")
|
||||
if chunk_size < 1024:
|
||||
raise ValueError(f"Chunk size {chunk_size} is too small (minimum 1024)")
|
||||
|
||||
# Calculate number of chunks
|
||||
num_chunks = (len(data) + chunk_size - 1) // chunk_size
|
||||
|
||||
# Check if we can fit all chunk sizes in the extra field
|
||||
# Extra field max is 65535 bytes, each chunk size takes 2 bytes, plus 6 bytes header
|
||||
max_chunks = (65535 - 6) // 2
|
||||
if num_chunks > max_chunks:
|
||||
raise ValueError(f"Too many chunks ({num_chunks}) for dictzip format (max {max_chunks})")
|
||||
|
||||
print(f"Compressing into {num_chunks} chunks of {chunk_size} bytes...")
|
||||
|
||||
# Compress each chunk and collect sizes
|
||||
compressed_chunks = []
|
||||
chunk_sizes = []
|
||||
|
||||
for i in range(num_chunks):
|
||||
start = i * chunk_size
|
||||
end = min(start + chunk_size, len(data))
|
||||
chunk_data = data[start:end]
|
||||
|
||||
compressed = compress_chunk(chunk_data, compression_level)
|
||||
compressed_chunks.append(compressed)
|
||||
chunk_sizes.append(len(compressed))
|
||||
|
||||
if (i + 1) % 500 == 0 or i == num_chunks - 1:
|
||||
print(f" Compressed chunk {i + 1}/{num_chunks}")
|
||||
|
||||
# Calculate CRC32 and size for gzip trailer
|
||||
crc32 = zlib.crc32(data) & 0xffffffff
|
||||
isize = len(data) & 0xffffffff
|
||||
|
||||
# Build the extra field
|
||||
# RA subfield: VER(2) + CHLEN(2) + CHCNT(2) + sizes[CHCNT](2 each)
|
||||
ra_subfield_len = 6 + 2 * num_chunks
|
||||
extra_field = bytearray()
|
||||
extra_field.extend(b'RA') # SI1, SI2
|
||||
extra_field.extend(struct.pack('<H', ra_subfield_len)) # LEN
|
||||
extra_field.extend(struct.pack('<H', 1)) # VER
|
||||
extra_field.extend(struct.pack('<H', chunk_size)) # CHLEN
|
||||
extra_field.extend(struct.pack('<H', num_chunks)) # CHCNT
|
||||
for size in chunk_sizes:
|
||||
if size > 65535:
|
||||
raise ValueError(f"Compressed chunk size {size} exceeds 65535 bytes")
|
||||
extra_field.extend(struct.pack('<H', size))
|
||||
|
||||
xlen = len(extra_field)
|
||||
|
||||
# Build gzip header
|
||||
# Flags: FEXTRA (0x04)
|
||||
timestamp = int(time.time())
|
||||
xfl = 2 if compression_level == 9 else (4 if compression_level == 1 else 0)
|
||||
|
||||
header = bytearray()
|
||||
header.extend(b'\x1f\x8b') # Magic number
|
||||
header.append(0x08) # Compression method (deflate)
|
||||
header.append(0x04) # Flags: FEXTRA
|
||||
header.extend(struct.pack('<I', timestamp)) # MTIME
|
||||
header.append(xfl) # XFL
|
||||
header.append(0xff) # OS (unknown)
|
||||
header.extend(struct.pack('<H', xlen)) # XLEN
|
||||
header.extend(extra_field)
|
||||
|
||||
# Write output file
|
||||
print(f"Writing {output_path}...")
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(header)
|
||||
for chunk in compressed_chunks:
|
||||
f.write(chunk)
|
||||
f.write(struct.pack('<I', crc32))
|
||||
f.write(struct.pack('<I', isize))
|
||||
|
||||
# Report stats
|
||||
output_size = output_path.stat().st_size
|
||||
ratio = (1 - output_size / len(data)) * 100
|
||||
print(f" Output size: {output_size:,} bytes ({ratio:.1f}% compression)")
|
||||
print(f" Chunk size: {chunk_size} bytes")
|
||||
print(f" Number of chunks: {num_chunks}")
|
||||
|
||||
|
||||
def verify_dictzip(path: Path) -> bool:
|
||||
"""Verify a dictzip file by reading its header and decompressing chunk by chunk."""
|
||||
print(f"Verifying {path}...")
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
# Read gzip header
|
||||
magic = f.read(2)
|
||||
if magic != b'\x1f\x8b':
|
||||
print(f" ERROR: Invalid gzip magic number")
|
||||
return False
|
||||
|
||||
method = f.read(1)[0]
|
||||
if method != 8:
|
||||
print(f" ERROR: Unknown compression method: {method}")
|
||||
return False
|
||||
|
||||
flags = f.read(1)[0]
|
||||
if not (flags & 0x04):
|
||||
print(f" ERROR: FEXTRA flag not set - not a dictzip file")
|
||||
return False
|
||||
|
||||
f.read(4) # MTIME
|
||||
f.read(1) # XFL
|
||||
f.read(1) # OS
|
||||
|
||||
# Read extra field
|
||||
xlen = struct.unpack('<H', f.read(2))[0]
|
||||
extra = f.read(xlen)
|
||||
|
||||
# Parse extra field for RA subfield
|
||||
pos = 0
|
||||
found_ra = False
|
||||
chlen = 0
|
||||
chcnt = 0
|
||||
chunk_sizes = []
|
||||
|
||||
while pos < len(extra):
|
||||
si1 = extra[pos]
|
||||
si2 = extra[pos + 1]
|
||||
slen = struct.unpack('<H', extra[pos + 2:pos + 4])[0]
|
||||
|
||||
if si1 == ord('R') and si2 == ord('A'):
|
||||
found_ra = True
|
||||
ra_data = extra[pos + 4:pos + 4 + slen]
|
||||
|
||||
ver = struct.unpack('<H', ra_data[0:2])[0]
|
||||
chlen = struct.unpack('<H', ra_data[2:4])[0]
|
||||
chcnt = struct.unpack('<H', ra_data[4:6])[0]
|
||||
|
||||
print(f" Version: {ver}")
|
||||
print(f" Chunk size: {chlen} bytes")
|
||||
print(f" Chunk count: {chcnt}")
|
||||
|
||||
# Verify chunk sizes array
|
||||
if len(ra_data) != 6 + 2 * chcnt:
|
||||
print(f" ERROR: Chunk sizes array length mismatch")
|
||||
return False
|
||||
|
||||
for i in range(chcnt):
|
||||
size = struct.unpack('<H', ra_data[6 + 2*i:8 + 2*i])[0]
|
||||
chunk_sizes.append(size)
|
||||
|
||||
print(f" Total compressed data: {sum(chunk_sizes):,} bytes")
|
||||
break
|
||||
|
||||
pos += 4 + slen
|
||||
|
||||
if not found_ra:
|
||||
print(f" ERROR: RA subfield not found - not a dictzip file")
|
||||
return False
|
||||
|
||||
# Decompress chunk by chunk (like the firmware does)
|
||||
data_start = f.tell()
|
||||
decompressed_data = bytearray()
|
||||
|
||||
try:
|
||||
for i, comp_size in enumerate(chunk_sizes):
|
||||
f.seek(data_start + sum(chunk_sizes[:i]))
|
||||
compressed_chunk = f.read(comp_size)
|
||||
|
||||
# Decompress using raw inflate (no zlib header)
|
||||
decompressor = zlib.decompressobj(-15)
|
||||
decompressed_chunk = decompressor.decompress(compressed_chunk)
|
||||
decompressed_chunk += decompressor.flush()
|
||||
decompressed_data.extend(decompressed_chunk)
|
||||
|
||||
print(f" Decompressed size: {len(decompressed_data):,} bytes")
|
||||
|
||||
# Verify CRC32 from trailer
|
||||
f.seek(-8, 2) # Seek to 8 bytes before end
|
||||
expected_crc = struct.unpack('<I', f.read(4))[0]
|
||||
expected_size = struct.unpack('<I', f.read(4))[0]
|
||||
|
||||
actual_crc = zlib.crc32(bytes(decompressed_data)) & 0xffffffff
|
||||
actual_size = len(decompressed_data) & 0xffffffff
|
||||
|
||||
if actual_crc != expected_crc:
|
||||
print(f" ERROR: CRC mismatch: expected {expected_crc:08x}, got {actual_crc:08x}")
|
||||
return False
|
||||
|
||||
if actual_size != expected_size:
|
||||
print(f" ERROR: Size mismatch: expected {expected_size}, got {actual_size}")
|
||||
return False
|
||||
|
||||
print(f" CRC32: {actual_crc:08x} (verified)")
|
||||
print(f" Verification: PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR: Decompression failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Recompress a dictzip file with a custom chunk size.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Recompress with 16KB chunks (recommended for ESP32):
|
||||
%(prog)s reader.dict reader.dict.dz --chunk-size 16384
|
||||
|
||||
# Recompress from existing .dz file:
|
||||
%(prog)s reader.dict.dz reader_small.dict.dz --chunk-size 16384
|
||||
|
||||
# Verify a dictzip file:
|
||||
%(prog)s --verify reader.dict.dz
|
||||
""")
|
||||
|
||||
parser.add_argument('input', nargs='?', help='Input .dict or .dict.dz file')
|
||||
parser.add_argument('output', nargs='?', help='Output .dict.dz file')
|
||||
parser.add_argument('--chunk-size', '-c', type=int, default=16384,
|
||||
help='Chunk size in bytes (default: 16384, i.e., 16KB)')
|
||||
parser.add_argument('--compression-level', '-l', type=int, default=9,
|
||||
choices=range(1, 10), metavar='1-9',
|
||||
help='Compression level 1-9 (default: 9)')
|
||||
parser.add_argument('--verify', '-v', action='store_true',
|
||||
help='Verify a dictzip file instead of compressing')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verify:
|
||||
if not args.input:
|
||||
parser.error("Input file required for verification")
|
||||
input_path = Path(args.input)
|
||||
if not input_path.exists():
|
||||
print(f"Error: File not found: {input_path}")
|
||||
sys.exit(1)
|
||||
success = verify_dictzip(input_path)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if not args.input or not args.output:
|
||||
parser.error("Both input and output files are required")
|
||||
|
||||
input_path = Path(args.input)
|
||||
output_path = Path(args.output)
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Error: Input file not found: {input_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if output_path.exists():
|
||||
response = input(f"Output file {output_path} exists. Overwrite? [y/N] ")
|
||||
if response.lower() != 'y':
|
||||
print("Aborted.")
|
||||
sys.exit(1)
|
||||
|
||||
# Read and decompress input if needed
|
||||
data = read_input_file(input_path)
|
||||
|
||||
# Create new dictzip with specified chunk size
|
||||
create_dictzip(data, output_path, args.chunk_size, args.compression_level)
|
||||
|
||||
# Verify the output
|
||||
print()
|
||||
if verify_dictzip(output_path):
|
||||
print(f"\nSuccess! Created {output_path} with {args.chunk_size}-byte chunks.")
|
||||
else:
|
||||
print(f"\nError: Verification failed!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -155,6 +155,11 @@ class CrossPointSettings {
|
||||
// Pinned list name (empty = none pinned)
|
||||
char pinnedListName[64] = "";
|
||||
|
||||
// Quick menu item order (indices 0-4 representing the 5 menu items)
|
||||
// Maps to QuickMenuAction enum: 0=Dictionary, 1=Bookmark, 2=ClearCache, 3=Orientation, 4=Settings
|
||||
// Default order: Bookmark(1), Dictionary(0), Orientation(3), Settings(4), ClearCache(2)
|
||||
uint8_t quickMenuOrder[5] = {1, 0, 3, 4, 2};
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
// Get singleton instance
|
||||
|
||||
@@ -52,6 +52,13 @@ void RecentBooksStore::clearAll() {
|
||||
Serial.printf("[%lu] [RBS] Cleared all recent books\n", millis());
|
||||
}
|
||||
|
||||
void RecentBooksStore::clearFromMemory() {
|
||||
const size_t count = recentBooks.size();
|
||||
recentBooks.clear();
|
||||
recentBooks.shrink_to_fit(); // Actually free the vector capacity
|
||||
Serial.printf("[%lu] [RBS] Cleared %d recent books from memory (not saved)\n", millis(), count);
|
||||
}
|
||||
|
||||
bool RecentBooksStore::saveToFile() const {
|
||||
// Make sure the directory exists
|
||||
SdMan.mkdir("/.crosspoint");
|
||||
|
||||
@@ -29,9 +29,14 @@ class RecentBooksStore {
|
||||
// Returns true if the book was found and removed
|
||||
bool removeBook(const std::string& path);
|
||||
|
||||
// Clear all recent books from the list
|
||||
// Clear all recent books from the list (and save to file)
|
||||
void clearAll();
|
||||
|
||||
// Clear recent books from memory without saving to file
|
||||
// Used to free memory when entering modes that don't need this data (e.g., File Transfer)
|
||||
// Call loadFromFile() to restore the data when needed again
|
||||
void clearFromMemory();
|
||||
|
||||
// Get the list of recent books (most recent first)
|
||||
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
|
||||
|
||||
|
||||
@@ -39,12 +39,14 @@ void DictionaryMenuActivity::onEnter() {
|
||||
void DictionaryMenuActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Wait until not rendering to delete task
|
||||
// Take mutex to ensure task isn't in render()
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
// Task is definitely not in render() because we hold the mutex.
|
||||
// Delete the task - it will never run again.
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free the task's stack
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
@@ -56,7 +58,10 @@ void DictionaryMenuActivity::loop() {
|
||||
// Handle back button - cancel
|
||||
// Use wasReleased to consume the full button event
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onCancel();
|
||||
// Copy callback before invoking - the callback may destroy this object
|
||||
// (and thus the original std::function) while still executing
|
||||
auto callback = onCancel;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,7 +69,9 @@ void DictionaryMenuActivity::loop() {
|
||||
// Use wasReleased to consume the full button event
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const DictionaryMode mode = (selectedIndex == 0) ? DictionaryMode::SELECT_FROM_SCREEN : DictionaryMode::ENTER_WORD;
|
||||
onModeSelected(mode);
|
||||
// Copy callback before invoking - the callback may destroy this object
|
||||
auto callback = onModeSelected;
|
||||
callback(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,7 +107,7 @@ void DictionaryMenuActivity::displayTaskLoop() {
|
||||
void DictionaryMenuActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Get margins using same pattern as reader + button hint space
|
||||
// Get margins with button hint space for all orientations
|
||||
int marginTop, marginRight, marginBottom, marginLeft;
|
||||
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
||||
|
||||
@@ -112,11 +119,11 @@ void DictionaryMenuActivity::render() const {
|
||||
const int contentWidth = pageWidth - marginLeft - marginRight;
|
||||
const int contentHeight = pageHeight - marginTop - marginBottom;
|
||||
|
||||
// Draw header with top margin
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD);
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Draw subtitle
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 50, "Look up a word");
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 30, "Look up a word");
|
||||
|
||||
// Draw menu items centered in content area
|
||||
constexpr int itemHeight = 50; // Height for each menu item (including description)
|
||||
@@ -137,9 +144,13 @@ void DictionaryMenuActivity::render() const {
|
||||
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected);
|
||||
}
|
||||
|
||||
// Draw help text at bottom
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", "");
|
||||
// Draw front button hints (Prev/Next for list navigation)
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "< Prev", "Next >");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
// Draw side button hints for up/down navigation (standard style with borders, always shown since list wraps)
|
||||
// Top button = up (prev), Bottom button = down (next)
|
||||
renderer.drawSideButtonHints(UI_10_FONT_ID, "<", ">");
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <DictHtmlParser.h>
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
#include "DictionaryMargins.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "fontIds.h"
|
||||
@@ -15,22 +19,28 @@ void DictionaryResultActivity::taskTrampoline(void* param) {
|
||||
void DictionaryResultActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
Serial.printf("[DICT-DBG] DictionaryResult onEnter, defLen=%u\n", rawDefinition.length());
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
currentPage = 0;
|
||||
|
||||
// Process definition for display
|
||||
if (!notFound) {
|
||||
Serial.printf("[DICT-DBG] Starting paginateDefinition...\n");
|
||||
paginateDefinition();
|
||||
Serial.printf("[DICT-DBG] Pagination done, %u pages\n", pages.size());
|
||||
}
|
||||
|
||||
updateRequired = true;
|
||||
|
||||
Serial.printf("[DICT-DBG] Creating display task...\n");
|
||||
xTaskCreate(&DictionaryResultActivity::taskTrampoline, "DictResultTask",
|
||||
4096, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
);
|
||||
Serial.printf("[DICT-DBG] Task created\n");
|
||||
}
|
||||
|
||||
void DictionaryResultActivity::onExit() {
|
||||
@@ -61,31 +71,58 @@ void DictionaryResultActivity::loop() {
|
||||
}
|
||||
|
||||
// Handle page navigation - use orientation-aware PageBack/PageForward buttons
|
||||
if (!notFound && pages.size() > 1) {
|
||||
if (!notFound && !pages.empty()) {
|
||||
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left);
|
||||
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Right);
|
||||
|
||||
if (prevPressed && currentPage > 0) {
|
||||
currentPage--;
|
||||
updateRequired = true;
|
||||
} else if (nextPressed && currentPage < static_cast<int>(pages.size()) - 1) {
|
||||
currentPage++;
|
||||
updateRequired = true;
|
||||
if (prevPressed) {
|
||||
if (currentPage > 0) {
|
||||
// Navigate within cached pages
|
||||
currentPage--;
|
||||
updateRequired = true;
|
||||
} else if (firstPageNumber > 1) {
|
||||
// At first cached page but earlier pages exist - re-parse to get them
|
||||
const int targetPage = firstPageNumber - 1; // Go to the page before current first
|
||||
Serial.printf("[DICT-DBG] Re-parsing to reach page %d\n", targetPage);
|
||||
reparseToPage(targetPage);
|
||||
updateRequired = true;
|
||||
}
|
||||
} else if (nextPressed) {
|
||||
// Check if we can navigate to existing cached page
|
||||
if (currentPage < static_cast<int>(pages.size()) - 1) {
|
||||
currentPage++;
|
||||
updateRequired = true;
|
||||
} else if (hasMoreContent) {
|
||||
// At end of cached pages but more content available - parse next chunk
|
||||
Serial.printf("[DICT-DBG] Parsing next chunk on navigation (page %d)\n", currentPage);
|
||||
parseNextChunk();
|
||||
|
||||
// After parsing (and possible page trimming), check if we can advance
|
||||
// Note: Don't compare page counts - trimming may keep size the same while adding new content
|
||||
if (currentPage < static_cast<int>(pages.size()) - 1) {
|
||||
currentPage++;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
// else: at true end of content, do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryResultActivity::paginateDefinition() {
|
||||
pages.clear();
|
||||
parsePosition = 0;
|
||||
hasMoreContent = false;
|
||||
firstPageNumber = 1;
|
||||
|
||||
if (rawDefinition.empty()) {
|
||||
notFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get margins using same pattern as reader + button hint space
|
||||
// Get margins with button hint space for all orientations
|
||||
int marginTop, marginRight, marginBottom, marginLeft;
|
||||
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
||||
|
||||
@@ -93,20 +130,61 @@ void DictionaryResultActivity::paginateDefinition() {
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Calculate available area for text (must match render() layout)
|
||||
constexpr int headerHeight = 80; // Space for word and header (relative to marginTop)
|
||||
constexpr int footerHeight = 30; // Space for page indicator
|
||||
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
|
||||
constexpr int footerHeight = 20; // Space for page indicator
|
||||
const int textMargin = marginLeft + 10;
|
||||
const int textWidth = pageWidth - textMargin - marginRight - 10;
|
||||
const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight;
|
||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const int linesPerPage = textHeight / lineHeight;
|
||||
|
||||
// Collect all TextBlocks from the HTML parser
|
||||
// For chunked parsing, we estimate how much HTML to parse at a time
|
||||
// Roughly: each line is ~40-60 chars, so one page ≈ linesPerPage * 60 bytes of text
|
||||
// With HTML overhead, multiply by ~2, plus buffer for finding break points
|
||||
constexpr size_t CHUNK_SIZE_BASE = 1500; // Base chunk size
|
||||
const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120));
|
||||
|
||||
Serial.printf("[DICT-DBG] Chunked parsing: defLen=%u, chunkSize=%u, linesPerPage=%d\n",
|
||||
rawDefinition.length(), chunkSize, linesPerPage);
|
||||
|
||||
// Determine how much to parse for first page
|
||||
size_t parseEnd;
|
||||
if (rawDefinition.length() <= chunkSize) {
|
||||
// Small definition - parse it all
|
||||
parseEnd = rawDefinition.length();
|
||||
hasMoreContent = false;
|
||||
} else {
|
||||
// Large definition - find a good break point
|
||||
parseEnd = findHtmlBreakPoint(rawDefinition, chunkSize / 2, chunkSize);
|
||||
hasMoreContent = (parseEnd < rawDefinition.length());
|
||||
}
|
||||
|
||||
// Extract the chunk to parse
|
||||
std::string chunk = rawDefinition.substr(0, parseEnd);
|
||||
parsePosition = parseEnd;
|
||||
|
||||
Serial.printf("[DICT-DBG] Parsing first chunk: 0-%u of %u, hasMore=%d\n",
|
||||
parseEnd, rawDefinition.length(), hasMoreContent);
|
||||
|
||||
// Parse this chunk into TextBlocks
|
||||
std::vector<std::shared_ptr<TextBlock>> allBlocks;
|
||||
DictHtmlParser::parse(rawDefinition, UI_10_FONT_ID, renderer, textWidth,
|
||||
[&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
|
||||
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
|
||||
[&allBlocks](std::shared_ptr<TextBlock> block) {
|
||||
allBlocks.push_back(block);
|
||||
});
|
||||
Serial.printf("[DICT-DBG] First chunk parsed, %u TextBlocks\n", allBlocks.size());
|
||||
|
||||
if (allBlocks.empty()) {
|
||||
notFound = true;
|
||||
// Check if there's more to parse - maybe first chunk had no displayable content
|
||||
if (hasMoreContent) {
|
||||
// Try parsing more
|
||||
parseNextChunk();
|
||||
if (pages.empty()) {
|
||||
notFound = true;
|
||||
}
|
||||
} else {
|
||||
notFound = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,6 +209,189 @@ void DictionaryResultActivity::paginateDefinition() {
|
||||
if (!currentPageBlocks.empty()) {
|
||||
pages.push_back(currentPageBlocks);
|
||||
}
|
||||
|
||||
Serial.printf("[DICT-DBG] Initial pagination: %u pages\n", pages.size());
|
||||
}
|
||||
|
||||
size_t DictionaryResultActivity::findHtmlBreakPoint(const std::string& html, size_t searchStart, size_t maxPos) {
|
||||
// Search backwards from maxPos for good HTML break points
|
||||
// Priority: </li>, </p>, </ol>, </ul>, </div> then any '>' then whitespace
|
||||
|
||||
if (maxPos >= html.length()) {
|
||||
return html.length();
|
||||
}
|
||||
|
||||
// Clamp searchStart to not exceed maxPos
|
||||
if (searchStart > maxPos) {
|
||||
searchStart = maxPos;
|
||||
}
|
||||
|
||||
// Search for closing block tags (best break points)
|
||||
const char* closingTags[] = {"</li>", "</p>", "</ol>", "</ul>", "</div>", "</dd>", "</dt>"};
|
||||
size_t bestBreak = std::string::npos;
|
||||
|
||||
for (const char* tag : closingTags) {
|
||||
size_t pos = html.rfind(tag, maxPos);
|
||||
if (pos != std::string::npos && pos >= searchStart) {
|
||||
// Found a closing tag - break after it
|
||||
size_t breakAfter = pos + strlen(tag);
|
||||
if (bestBreak == std::string::npos || breakAfter > bestBreak) {
|
||||
bestBreak = breakAfter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestBreak != std::string::npos) {
|
||||
return bestBreak;
|
||||
}
|
||||
|
||||
// Fallback: search for any '>' (end of tag)
|
||||
size_t tagEnd = html.rfind('>', maxPos);
|
||||
if (tagEnd != std::string::npos && tagEnd >= searchStart) {
|
||||
return tagEnd + 1;
|
||||
}
|
||||
|
||||
// Last resort: search for whitespace
|
||||
for (size_t i = maxPos; i >= searchStart && i != std::string::npos; i--) {
|
||||
if (std::isspace(static_cast<unsigned char>(html[i]))) {
|
||||
return i + 1;
|
||||
}
|
||||
if (i == 0) break;
|
||||
}
|
||||
|
||||
// No good break point found - use maxPos
|
||||
return maxPos;
|
||||
}
|
||||
|
||||
void DictionaryResultActivity::parseNextChunk() {
|
||||
if (!hasMoreContent || parsePosition >= rawDefinition.length()) {
|
||||
hasMoreContent = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[DICT-DBG] parseNextChunk starting at position %u of %u\n",
|
||||
parsePosition, rawDefinition.length());
|
||||
|
||||
// Get margins with button hint space for all orientations
|
||||
int marginTop, marginRight, marginBottom, marginLeft;
|
||||
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Calculate text area dimensions (must match paginateDefinition and render)
|
||||
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
|
||||
constexpr int footerHeight = 20; // Space for page indicator
|
||||
const int textMargin = marginLeft + 10;
|
||||
const int textWidth = pageWidth - textMargin - marginRight - 10;
|
||||
const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight;
|
||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const int linesPerPage = textHeight / lineHeight;
|
||||
|
||||
// Chunk size estimation (same as paginateDefinition)
|
||||
constexpr size_t CHUNK_SIZE_BASE = 1500;
|
||||
const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120));
|
||||
|
||||
// Determine parse range for this chunk
|
||||
size_t parseStart = parsePosition;
|
||||
size_t parseEnd;
|
||||
|
||||
if (parsePosition + chunkSize >= rawDefinition.length()) {
|
||||
// This will be the last chunk
|
||||
parseEnd = rawDefinition.length();
|
||||
hasMoreContent = false;
|
||||
} else {
|
||||
// Find a good break point
|
||||
parseEnd = findHtmlBreakPoint(rawDefinition, parsePosition + chunkSize / 2, parsePosition + chunkSize);
|
||||
hasMoreContent = (parseEnd < rawDefinition.length());
|
||||
}
|
||||
|
||||
// Extract the chunk to parse
|
||||
std::string chunk = rawDefinition.substr(parseStart, parseEnd - parseStart);
|
||||
parsePosition = parseEnd;
|
||||
|
||||
Serial.printf("[DICT-DBG] Parsing chunk %u-%u, hasMore=%d\n", parseStart, parseEnd, hasMoreContent);
|
||||
|
||||
// Parse this chunk into TextBlocks
|
||||
std::vector<std::shared_ptr<TextBlock>> allBlocks;
|
||||
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
|
||||
[&allBlocks](std::shared_ptr<TextBlock> block) {
|
||||
allBlocks.push_back(block);
|
||||
});
|
||||
|
||||
Serial.printf("[DICT-DBG] Chunk parsed, %u TextBlocks\n", allBlocks.size());
|
||||
|
||||
if (allBlocks.empty()) {
|
||||
// No content in this chunk - try parsing more if available
|
||||
if (hasMoreContent) {
|
||||
parseNextChunk();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Paginate: group TextBlocks into pages based on available height
|
||||
std::vector<std::shared_ptr<TextBlock>> currentPageBlocks;
|
||||
int currentY = 0;
|
||||
|
||||
for (const auto& block : allBlocks) {
|
||||
if (currentY + lineHeight > textHeight && !currentPageBlocks.empty()) {
|
||||
// Page is full, start new page
|
||||
pages.push_back(currentPageBlocks);
|
||||
currentPageBlocks.clear();
|
||||
currentY = 0;
|
||||
}
|
||||
|
||||
currentPageBlocks.push_back(block);
|
||||
currentY += lineHeight;
|
||||
}
|
||||
|
||||
// Add remaining blocks as last page
|
||||
if (!currentPageBlocks.empty()) {
|
||||
pages.push_back(currentPageBlocks);
|
||||
}
|
||||
|
||||
// Trim old pages if we exceed the limit to prevent memory exhaustion
|
||||
while (static_cast<int>(pages.size()) > MAX_CACHED_PAGES && currentPage > 0) {
|
||||
// Remove the oldest page and adjust indices
|
||||
pages.erase(pages.begin());
|
||||
currentPage--;
|
||||
firstPageNumber++;
|
||||
Serial.printf("[DICT-DBG] Trimmed old page, firstPageNumber now %d\n", firstPageNumber);
|
||||
}
|
||||
|
||||
Serial.printf("[DICT-DBG] After chunk: %u cached pages (pages %d-%d)\n",
|
||||
pages.size(), firstPageNumber, firstPageNumber + static_cast<int>(pages.size()) - 1);
|
||||
}
|
||||
|
||||
void DictionaryResultActivity::reparseToPage(int targetPageNumber) {
|
||||
// Re-parse from the beginning to reach an earlier page that was trimmed
|
||||
// This allows backward navigation through the entire definition
|
||||
|
||||
Serial.printf("[DICT-DBG] reparseToPage: target=%d, clearing and re-parsing\n", targetPageNumber);
|
||||
|
||||
// Clear current state and start fresh
|
||||
pages.clear();
|
||||
parsePosition = 0;
|
||||
firstPageNumber = 1;
|
||||
hasMoreContent = !rawDefinition.empty();
|
||||
|
||||
// Parse chunks until we have the target page
|
||||
while (hasMoreContent && firstPageNumber + static_cast<int>(pages.size()) - 1 < targetPageNumber) {
|
||||
parseNextChunk();
|
||||
}
|
||||
|
||||
// Now position currentPage to show the target page
|
||||
if (targetPageNumber >= firstPageNumber &&
|
||||
targetPageNumber < firstPageNumber + static_cast<int>(pages.size())) {
|
||||
currentPage = targetPageNumber - firstPageNumber;
|
||||
} else {
|
||||
// Target page doesn't exist (definition is shorter than expected)
|
||||
currentPage = static_cast<int>(pages.size()) - 1;
|
||||
if (currentPage < 0) currentPage = 0;
|
||||
}
|
||||
|
||||
Serial.printf("[DICT-DBG] reparseToPage done: currentPage=%d, firstPageNumber=%d, pages=%u\n",
|
||||
currentPage, firstPageNumber, pages.size());
|
||||
}
|
||||
|
||||
void DictionaryResultActivity::displayTaskLoop() {
|
||||
@@ -148,17 +409,15 @@ void DictionaryResultActivity::displayTaskLoop() {
|
||||
void DictionaryResultActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Get margins using same pattern as reader + button hint space
|
||||
// Get margins with button hint space for all orientations
|
||||
int marginTop, marginRight, marginBottom, marginLeft;
|
||||
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
||||
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Draw header with top margin
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Draw word being looked up (bold)
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 50, lookupWord.c_str(), true, EpdFontFamily::BOLD);
|
||||
// Draw header - "Dictionary" title and lookup word
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 30, lookupWord.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
if (notFound) {
|
||||
// Show not found message (centered in content area)
|
||||
@@ -166,10 +425,12 @@ void DictionaryResultActivity::render() const {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, "Word not found");
|
||||
} else if (!pages.empty()) {
|
||||
// Draw definition text using TextBlocks with rich formatting
|
||||
const int textStartY = marginTop + 80;
|
||||
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
|
||||
constexpr int footerHeight = 20; // Space for page indicator
|
||||
const int textStartY = marginTop + headerHeight;
|
||||
const int textMargin = marginLeft + 10;
|
||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const int bottomLimit = pageHeight - marginBottom - 25; // Leave space for page indicator
|
||||
const int bottomLimit = pageHeight - marginBottom - footerHeight;
|
||||
|
||||
const auto& pageBlocks = pages[currentPage];
|
||||
int y = textStartY;
|
||||
@@ -181,19 +442,36 @@ void DictionaryResultActivity::render() const {
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
// Draw page indicator if multiple pages
|
||||
if (pages.size() > 1) {
|
||||
char pageIndicator[32];
|
||||
snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d", currentPage + 1, static_cast<int>(pages.size()));
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - marginBottom - 5, pageIndicator);
|
||||
// Draw page indicator if multiple pages or more content available
|
||||
const bool hasMultiplePages = pages.size() > 1 || hasMoreContent || firstPageNumber > 1;
|
||||
if (hasMultiplePages) {
|
||||
char pageIndicator[48];
|
||||
const int displayPageNum = firstPageNumber + currentPage;
|
||||
const int lastKnownPage = firstPageNumber + static_cast<int>(pages.size()) - 1;
|
||||
if (hasMoreContent) {
|
||||
snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d+", displayPageNum, lastKnownPage);
|
||||
} else {
|
||||
snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d", displayPageNum, lastKnownPage);
|
||||
}
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - marginBottom - 15, pageIndicator);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw button hints
|
||||
const char* leftHint = (pages.size() > 1 && currentPage > 0) ? "< Prev" : "";
|
||||
const char* rightHint = (pages.size() > 1 && currentPage < static_cast<int>(pages.size()) - 1) ? "Next >" : "";
|
||||
// Show navigation hints when there are multiple pages or more content to load
|
||||
// canGoBack is true if we have previous cached pages OR if earlier pages were trimmed
|
||||
const bool canGoBack = currentPage > 0 || firstPageNumber > 1;
|
||||
const bool canGoForward = currentPage < static_cast<int>(pages.size()) - 1 || hasMoreContent;
|
||||
const char* leftHint = canGoBack ? "< Prev" : "";
|
||||
const char* rightHint = canGoForward ? "Next >" : "";
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Search", leftHint, rightHint);
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
// Draw side button hints for page navigation (rotated 90° CW: ">" appears as "^", "<" as "v")
|
||||
// Top physical button = PageBack (prev), Bottom physical button = PageForward (next)
|
||||
const char* sideTopHint = canGoBack ? "<" : "";
|
||||
const char* sideBottomHint = canGoForward ? ">" : "";
|
||||
renderer.drawSideButtonHints(UI_10_FONT_ID, sideTopHint, sideBottomHint);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
@@ -26,14 +26,24 @@ class DictionaryResultActivity final : public Activity {
|
||||
const std::function<void()> onSearchAnother;
|
||||
|
||||
// Pagination - each page contains TextBlocks with styled text
|
||||
// We limit cached pages to prevent memory exhaustion on long definitions
|
||||
static constexpr int MAX_CACHED_PAGES = 4;
|
||||
std::vector<std::vector<std::shared_ptr<TextBlock>>> pages;
|
||||
int currentPage = 0;
|
||||
int currentPage = 0; // Index into pages vector
|
||||
int firstPageNumber = 1; // The page number of pages[0] (1-based for display)
|
||||
bool notFound = false;
|
||||
|
||||
// Chunked parsing state - parse definition on-demand as user navigates
|
||||
size_t parsePosition = 0; // Current position in rawDefinition HTML
|
||||
bool hasMoreContent = false; // True if more HTML remains to parse
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render() const;
|
||||
void paginateDefinition();
|
||||
void parseNextChunk();
|
||||
void reparseToPage(int targetPageNumber); // Re-parse from beginning to reach earlier page
|
||||
static size_t findHtmlBreakPoint(const std::string& html, size_t searchStart, size_t maxPos);
|
||||
|
||||
public:
|
||||
/**
|
||||
|
||||
@@ -235,14 +235,14 @@ void DictionarySearchActivity::displayTaskLoop() {
|
||||
void DictionarySearchActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Get margins using same pattern as reader + button hint space
|
||||
// Get margins with button hint space for all orientations
|
||||
int marginTop, marginRight, marginBottom, marginLeft;
|
||||
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
||||
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Draw header with top margin
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD);
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
|
||||
|
||||
if (isSearching) {
|
||||
// Show searching status with word and animated ellipsis
|
||||
|
||||
@@ -223,10 +223,12 @@ void EpubWordSelectionActivity::displayTaskLoop() {
|
||||
void EpubWordSelectionActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Get margins using same pattern as reader + button hint space
|
||||
// Get margins with button hint space for all orientations
|
||||
int marginTop, marginRight, marginBottom, marginLeft;
|
||||
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
||||
|
||||
const auto screenHeight = renderer.getScreenHeight();
|
||||
|
||||
// Draw the page content (uses pre-calculated offsets from reader)
|
||||
// The page already has proper offsets, so render as-is
|
||||
if (page) {
|
||||
@@ -246,14 +248,20 @@ void EpubWordSelectionActivity::render() const {
|
||||
renderer.drawText(fontId, selected.x, selected.y, selected.text.c_str(), false, selected.style);
|
||||
}
|
||||
|
||||
// Draw instruction text - position it just above the front button area
|
||||
const auto screenHeight = renderer.getScreenHeight();
|
||||
// Draw instruction text - always show, positioned just above the front button area
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10,
|
||||
"Navigate with arrows, select with confirm");
|
||||
|
||||
// Draw button hints
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< >", "");
|
||||
// Draw button hints with proper left/right navigation labels
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< Prev", "Next >");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
// Draw side button hints for up/down line navigation (no border, small font)
|
||||
// Top physical button = Up (prev line), Bottom physical button = Down (next line)
|
||||
const int lastLine = findLineForWordIndex(static_cast<int>(allWords.size()) - 1);
|
||||
const char* sideTopHint = (currentLineIndex > 0) ? "UP" : "";
|
||||
const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : "";
|
||||
renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border
|
||||
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
}
|
||||
|
||||
@@ -236,6 +236,15 @@ void EpubReaderActivity::loop() {
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||
// Start over from beginning
|
||||
currentSpineIndex = 0;
|
||||
nextPageNumber = 0;
|
||||
showingEndOfBookPrompt = false;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -496,6 +505,28 @@ void EpubReaderActivity::loop() {
|
||||
self->onGoToClearCache();
|
||||
return;
|
||||
}
|
||||
self->updateRequired = true;
|
||||
} else if (action == QuickMenuAction::TOGGLE_ORIENTATION) {
|
||||
// Toggle between Portrait and Landscape CCW
|
||||
if (SETTINGS.orientation == CrossPointSettings::ORIENTATION::PORTRAIT) {
|
||||
SETTINGS.orientation = CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
} else {
|
||||
SETTINGS.orientation = CrossPointSettings::ORIENTATION::PORTRAIT;
|
||||
}
|
||||
SETTINGS.saveToFile();
|
||||
|
||||
// Apply new orientation to renderer
|
||||
if (SETTINGS.orientation == CrossPointSettings::ORIENTATION::PORTRAIT) {
|
||||
self->renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
} else {
|
||||
self->renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
|
||||
}
|
||||
|
||||
// Force section reload with new orientation's viewport dimensions
|
||||
xSemaphoreTake(cachedMutex, portMAX_DELAY);
|
||||
self->section.reset();
|
||||
xSemaphoreGive(cachedMutex);
|
||||
|
||||
self->updateRequired = true;
|
||||
} else if (action == QuickMenuAction::GO_TO_SETTINGS) {
|
||||
// Navigate to Settings activity
|
||||
@@ -966,7 +997,7 @@ void EpubReaderActivity::renderEndOfBookPrompt() {
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -171,6 +171,14 @@ void TxtReaderActivity::loop() {
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||
// Start over from beginning
|
||||
currentPage = 0;
|
||||
showingEndOfBookPrompt = false;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -909,7 +917,7 @@ void TxtReaderActivity::renderEndOfBookPrompt() {
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "KeyboardEntryActivity.h"
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/dictionary/DictionaryMargins.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
// Keyboard layouts - lowercase
|
||||
@@ -249,7 +249,7 @@ void KeyboardEntryActivity::loop() {
|
||||
}
|
||||
|
||||
void KeyboardEntryActivity::render() const {
|
||||
// Get margins using same pattern as reader + button hint space
|
||||
// Get margins with button hint space for all orientations
|
||||
int marginTop, marginRight, marginBottom, marginLeft;
|
||||
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
||||
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEM_COUNT = 4;
|
||||
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"};
|
||||
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = {"Look up a word", "Add bookmark to this page",
|
||||
"Free up storage space", "Open settings menu"};
|
||||
const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = {"Look up a word", "Remove bookmark from this page",
|
||||
"Free up storage space", "Open settings menu"};
|
||||
// Base menu item count (reorderable items)
|
||||
constexpr int BASE_MENU_ITEM_COUNT = 5;
|
||||
// Total display count including "Edit List Order"
|
||||
constexpr int DISPLAY_ITEM_COUNT = 6;
|
||||
|
||||
// Menu items indexed by QuickMenuAction enum value
|
||||
// 0=Dictionary, 1=Bookmark, 2=ClearCache, 3=Orientation, 4=Settings
|
||||
const char* MENU_ITEMS[BASE_MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Rotate Screen", "Settings"};
|
||||
const char* MENU_DESCRIPTIONS_ADD[BASE_MENU_ITEM_COUNT] = {"Look up a word", "Add bookmark to this page",
|
||||
"Free up storage space", "Toggle screen orientation",
|
||||
"Open settings menu"};
|
||||
const char* MENU_DESCRIPTIONS_REMOVE[BASE_MENU_ITEM_COUNT] = {"Look up a word", "Remove bookmark from this page",
|
||||
"Free up storage space", "Toggle screen orientation",
|
||||
"Open settings menu"};
|
||||
} // namespace
|
||||
|
||||
void QuickMenuActivity::taskTrampoline(void* param) {
|
||||
@@ -53,6 +62,16 @@ void QuickMenuActivity::onExit() {
|
||||
}
|
||||
|
||||
void QuickMenuActivity::loop() {
|
||||
if (editMode) {
|
||||
// Edit mode logic
|
||||
handleEditMode();
|
||||
} else {
|
||||
// Normal mode logic
|
||||
handleNormalMode();
|
||||
}
|
||||
}
|
||||
|
||||
void QuickMenuActivity::handleNormalMode() {
|
||||
// Handle back button - cancel
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onCancel();
|
||||
@@ -61,8 +80,22 @@ void QuickMenuActivity::loop() {
|
||||
|
||||
// Handle confirm button - select current option
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Last item is "Edit List Order"
|
||||
if (selectedIndex == DISPLAY_ITEM_COUNT - 1) {
|
||||
// Enter edit mode - copy current order to local buffer
|
||||
for (int i = 0; i < BASE_MENU_ITEM_COUNT; i++) {
|
||||
localOrder[i] = SETTINGS.quickMenuOrder[i];
|
||||
}
|
||||
editMode = true;
|
||||
selectedIndex = 0; // Start at first item in edit mode
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the action from the order array
|
||||
const int actionIndex = SETTINGS.quickMenuOrder[selectedIndex];
|
||||
QuickMenuAction action;
|
||||
switch (selectedIndex) {
|
||||
switch (actionIndex) {
|
||||
case 0:
|
||||
action = QuickMenuAction::DICTIONARY;
|
||||
break;
|
||||
@@ -73,6 +106,9 @@ void QuickMenuActivity::loop() {
|
||||
action = QuickMenuAction::CLEAR_CACHE;
|
||||
break;
|
||||
case 3:
|
||||
action = QuickMenuAction::TOGGLE_ORIENTATION;
|
||||
break;
|
||||
case 4:
|
||||
default:
|
||||
action = QuickMenuAction::GO_TO_SETTINGS;
|
||||
break;
|
||||
@@ -88,10 +124,69 @@ void QuickMenuActivity::loop() {
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Right);
|
||||
|
||||
if (prevPressed) {
|
||||
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
|
||||
selectedIndex = (selectedIndex + DISPLAY_ITEM_COUNT - 1) % DISPLAY_ITEM_COUNT;
|
||||
updateRequired = true;
|
||||
} else if (nextPressed) {
|
||||
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
|
||||
selectedIndex = (selectedIndex + 1) % DISPLAY_ITEM_COUNT;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void QuickMenuActivity::handleEditMode() {
|
||||
// Handle back button - save and exit edit mode
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
// Save the local order to settings
|
||||
for (int i = 0; i < BASE_MENU_ITEM_COUNT; i++) {
|
||||
SETTINGS.quickMenuOrder[i] = localOrder[i];
|
||||
}
|
||||
SETTINGS.saveToFile();
|
||||
editMode = false;
|
||||
movingIndex = -1;
|
||||
selectedIndex = DISPLAY_ITEM_COUNT - 1; // Select "Edit List Order" when exiting
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle confirm button - pick or place item
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (movingIndex < 0) {
|
||||
// No item selected yet - pick up the current item
|
||||
movingIndex = selectedIndex;
|
||||
} else {
|
||||
// Item is being moved - place it at the current position
|
||||
if (movingIndex != selectedIndex) {
|
||||
// Remove item from old position and insert at new position
|
||||
const uint8_t movingItem = localOrder[movingIndex];
|
||||
if (movingIndex < selectedIndex) {
|
||||
// Moving down - shift items up
|
||||
for (int i = movingIndex; i < selectedIndex; i++) {
|
||||
localOrder[i] = localOrder[i + 1];
|
||||
}
|
||||
} else {
|
||||
// Moving up - shift items down
|
||||
for (int i = movingIndex; i > selectedIndex; i--) {
|
||||
localOrder[i] = localOrder[i - 1];
|
||||
}
|
||||
}
|
||||
localOrder[selectedIndex] = movingItem;
|
||||
}
|
||||
movingIndex = -1; // Deselect
|
||||
}
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation - just move cursor
|
||||
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left);
|
||||
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Right);
|
||||
|
||||
if (prevPressed && selectedIndex > 0) {
|
||||
selectedIndex--;
|
||||
updateRequired = true;
|
||||
} else if (nextPressed && selectedIndex < BASE_MENU_ITEM_COUNT - 1) {
|
||||
selectedIndex++;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
@@ -120,46 +215,110 @@ void QuickMenuActivity::render() const {
|
||||
const int bezelRight = renderer.getBezelOffsetRight();
|
||||
const int bezelBottom = renderer.getBezelOffsetBottom();
|
||||
|
||||
// Calculate usable content area
|
||||
const int marginLeft = 20 + bezelLeft;
|
||||
const int marginRight = 20 + bezelRight;
|
||||
const int marginTop = 15 + bezelTop;
|
||||
const int contentWidth = pageWidth - marginLeft - marginRight;
|
||||
const int contentHeight = pageHeight - marginTop - 60 - bezelBottom; // 60 for button hints
|
||||
// Button hint space constants
|
||||
constexpr int FRONT_BUTTON_SPACE = 45; // 40px button height + 5px padding
|
||||
constexpr int SIDE_BUTTON_SPACE = 50; // 45px button area + 5px padding
|
||||
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop, "Quick Menu", true, EpdFontFamily::BOLD);
|
||||
// Calculate button hint margins based on orientation
|
||||
// Physical button locations (fixed on device):
|
||||
// - Front buttons: physical bottom in portrait
|
||||
// - Side buttons: physical right in portrait
|
||||
// These map to different logical edges depending on orientation
|
||||
int frontBtnMarginTop = 0, frontBtnMarginBottom = 0, frontBtnMarginLeft = 0, frontBtnMarginRight = 0;
|
||||
int sideBtnMarginTop = 0, sideBtnMarginBottom = 0, sideBtnMarginLeft = 0, sideBtnMarginRight = 0;
|
||||
|
||||
switch (renderer.getOrientation()) {
|
||||
case GfxRenderer::Portrait:
|
||||
// Front buttons at logical BOTTOM, Side buttons at logical RIGHT
|
||||
frontBtnMarginBottom = FRONT_BUTTON_SPACE;
|
||||
sideBtnMarginRight = SIDE_BUTTON_SPACE;
|
||||
break;
|
||||
case GfxRenderer::LandscapeClockwise:
|
||||
// Front buttons at logical LEFT, Side buttons at logical BOTTOM
|
||||
frontBtnMarginLeft = FRONT_BUTTON_SPACE;
|
||||
sideBtnMarginBottom = SIDE_BUTTON_SPACE;
|
||||
break;
|
||||
case GfxRenderer::PortraitInverted:
|
||||
// Front buttons at logical TOP, Side buttons at logical LEFT
|
||||
frontBtnMarginTop = FRONT_BUTTON_SPACE;
|
||||
sideBtnMarginLeft = SIDE_BUTTON_SPACE;
|
||||
break;
|
||||
case GfxRenderer::LandscapeCounterClockwise:
|
||||
// Front buttons at logical RIGHT, Side buttons at logical TOP
|
||||
frontBtnMarginRight = FRONT_BUTTON_SPACE;
|
||||
sideBtnMarginTop = SIDE_BUTTON_SPACE;
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate usable content area with bezel and button hint margins
|
||||
const int marginLeft = 20 + bezelLeft + frontBtnMarginLeft + sideBtnMarginLeft;
|
||||
const int marginRight = 20 + bezelRight + frontBtnMarginRight + sideBtnMarginRight;
|
||||
const int marginTop = 15 + bezelTop + frontBtnMarginTop + sideBtnMarginTop;
|
||||
const int marginBottom = 15 + bezelBottom + frontBtnMarginBottom + sideBtnMarginBottom;
|
||||
const int contentWidth = pageWidth - marginLeft - marginRight;
|
||||
const int contentHeight = pageHeight - marginTop - marginBottom;
|
||||
|
||||
// Draw header - different text in edit mode
|
||||
const char* headerText = editMode ? "Edit Menu Order" : "Quick Menu";
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, marginTop, headerText, true, EpdFontFamily::BOLD);
|
||||
|
||||
// Select descriptions based on bookmark state
|
||||
const char* const* descriptions = isPageBookmarked ? MENU_DESCRIPTIONS_REMOVE : MENU_DESCRIPTIONS_ADD;
|
||||
|
||||
// Get the order array to use (local copy in edit mode, settings otherwise)
|
||||
const uint8_t* order = editMode ? localOrder : SETTINGS.quickMenuOrder;
|
||||
|
||||
// Draw menu items centered in content area
|
||||
constexpr int itemHeight = 50; // Height for each menu item (including description)
|
||||
const int startY = marginTop + (contentHeight - (MENU_ITEM_COUNT * itemHeight)) / 2;
|
||||
const int startY = marginTop + (contentHeight - (DISPLAY_ITEM_COUNT * itemHeight)) / 2;
|
||||
|
||||
for (int i = 0; i < MENU_ITEM_COUNT; i++) {
|
||||
for (int i = 0; i < DISPLAY_ITEM_COUNT; i++) {
|
||||
const int itemY = startY + i * itemHeight;
|
||||
const bool isSelected = (i == selectedIndex);
|
||||
const bool isBeingMoved = (editMode && i == movingIndex);
|
||||
|
||||
// Draw selection highlight (black fill) for selected item
|
||||
if (isSelected) {
|
||||
renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
|
||||
}
|
||||
|
||||
// Draw menu item text
|
||||
const char* itemText = MENU_ITEMS[i];
|
||||
// For bookmark item, show different text based on state
|
||||
if (i == 1) {
|
||||
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
|
||||
// Draw outline for item being moved (when cursor is elsewhere)
|
||||
if (isBeingMoved && !isSelected) {
|
||||
renderer.drawRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
|
||||
}
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
|
||||
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected);
|
||||
// Last item is always "Edit List Order" (fixed, not in the order array)
|
||||
if (i == DISPLAY_ITEM_COUNT - 1) {
|
||||
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, "- Edit List Order -", !isSelected);
|
||||
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, "Customize menu order", !isSelected);
|
||||
} else {
|
||||
// Get the action index from the order array
|
||||
const int actionIndex = order[i];
|
||||
|
||||
// Draw menu item text - add indicator for item being moved
|
||||
const char* itemText = MENU_ITEMS[actionIndex];
|
||||
// For bookmark item (action index 1), show different text based on state
|
||||
if (actionIndex == 1) {
|
||||
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
|
||||
}
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
|
||||
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[actionIndex], !isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw help text at bottom
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
// Draw help text at bottom - different hints for edit mode
|
||||
if (editMode) {
|
||||
const char* confirmLabel = (movingIndex < 0) ? "Pick" : "Place";
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Done", confirmLabel, "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
// Side button hints for navigation
|
||||
renderer.drawSideButtonHints(UI_10_FONT_ID, "<", ">");
|
||||
} else {
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "< Prev", "Next >");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
// Side button hints for up/down navigation
|
||||
renderer.drawSideButtonHints(UI_10_FONT_ID, "<", ">");
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#include "../Activity.h"
|
||||
|
||||
// Enum for quick menu selection
|
||||
enum class QuickMenuAction { DICTIONARY, ADD_BOOKMARK, CLEAR_CACHE, GO_TO_SETTINGS };
|
||||
enum class QuickMenuAction { DICTIONARY, ADD_BOOKMARK, CLEAR_CACHE, TOGGLE_ORIENTATION, GO_TO_SETTINGS };
|
||||
|
||||
/**
|
||||
* QuickMenuActivity presents a quick access menu triggered by short power button press.
|
||||
@@ -28,9 +28,16 @@ class QuickMenuActivity final : public Activity {
|
||||
const std::function<void()> onCancel;
|
||||
const bool isPageBookmarked; // True if current page already has a bookmark
|
||||
|
||||
// Edit mode state
|
||||
bool editMode = false; // True when in edit mode
|
||||
int movingIndex = -1; // Index of item being moved (-1 if none)
|
||||
uint8_t localOrder[5] = {0}; // Local copy of order for editing
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render() const;
|
||||
void handleNormalMode();
|
||||
void handleEditMode();
|
||||
|
||||
public:
|
||||
explicit QuickMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
|
||||
27
src/main.cpp
27
src/main.cpp
@@ -381,6 +381,15 @@ void onGoToListsOrPinned() {
|
||||
|
||||
void onGoToFileTransfer() {
|
||||
exitActivity();
|
||||
|
||||
// Free memory not needed during file transfer to maximize heap for webserver
|
||||
RECENT_BOOKS.clearFromMemory();
|
||||
APP_STATE.openBookTitle.clear();
|
||||
APP_STATE.openBookTitle.shrink_to_fit();
|
||||
APP_STATE.openBookAuthor.clear();
|
||||
APP_STATE.openBookAuthor.shrink_to_fit();
|
||||
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
|
||||
|
||||
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
||||
}
|
||||
|
||||
@@ -396,12 +405,24 @@ void onGoToClearCache() {
|
||||
|
||||
void onGoToMyLibrary() {
|
||||
exitActivity();
|
||||
|
||||
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
|
||||
if (RECENT_BOOKS.getCount() == 0) {
|
||||
RECENT_BOOKS.loadFromFile();
|
||||
}
|
||||
|
||||
enterNewActivity(
|
||||
new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
|
||||
}
|
||||
|
||||
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
|
||||
exitActivity();
|
||||
|
||||
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
|
||||
if (RECENT_BOOKS.getCount() == 0) {
|
||||
RECENT_BOOKS.loadFromFile();
|
||||
}
|
||||
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
|
||||
onGoToBookmarkList, tab, path));
|
||||
}
|
||||
@@ -413,6 +434,12 @@ void onGoToBrowser() {
|
||||
|
||||
void onGoHome() {
|
||||
exitActivity();
|
||||
|
||||
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
|
||||
if (RECENT_BOOKS.getCount() == 0) {
|
||||
RECENT_BOOKS.loadFromFile();
|
||||
}
|
||||
|
||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned,
|
||||
onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser));
|
||||
}
|
||||
|
||||
@@ -441,17 +441,33 @@ void CrossPointWebServer::handleFileListData() const {
|
||||
showHidden = server->arg("showHidden") == "true";
|
||||
}
|
||||
|
||||
// Check client connection before starting
|
||||
if (!server->client().connected()) {
|
||||
Serial.printf("[%lu] [WEB] Client disconnected before file list could start\n", millis());
|
||||
return;
|
||||
}
|
||||
|
||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server->send(200, "application/json", "");
|
||||
server->sendContent("[");
|
||||
if (!sendContentSafe("[")) {
|
||||
Serial.printf("[%lu] [WEB] Client disconnected at start of file list\n", millis());
|
||||
return;
|
||||
}
|
||||
|
||||
char output[512];
|
||||
constexpr size_t outputSize = sizeof(output);
|
||||
bool seenFirst = false;
|
||||
bool clientDisconnected = false;
|
||||
JsonDocument doc;
|
||||
|
||||
scanFiles(
|
||||
currentPath.c_str(),
|
||||
[this, &output, &doc, seenFirst](const FileInfo& info) mutable {
|
||||
[this, &output, &doc, &seenFirst, &clientDisconnected](const FileInfo& info) mutable {
|
||||
// Skip remaining files if client already disconnected
|
||||
if (clientDisconnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
doc.clear();
|
||||
doc["name"] = info.name;
|
||||
doc["size"] = info.size;
|
||||
@@ -475,18 +491,33 @@ void CrossPointWebServer::handleFileListData() const {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send comma separator before all entries except the first
|
||||
if (seenFirst) {
|
||||
server->sendContent(",");
|
||||
if (!sendContentSafe(",")) {
|
||||
clientDisconnected = true;
|
||||
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
seenFirst = true;
|
||||
}
|
||||
server->sendContent(output);
|
||||
|
||||
// Send the JSON entry with flow control
|
||||
if (!sendContentSafe(output)) {
|
||||
clientDisconnected = true;
|
||||
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
|
||||
return;
|
||||
}
|
||||
},
|
||||
showHidden);
|
||||
server->sendContent("]");
|
||||
// End of streamed response, empty chunk to signal client
|
||||
server->sendContent("");
|
||||
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
||||
|
||||
// Only send closing bracket if client is still connected
|
||||
if (!clientDisconnected) {
|
||||
sendContentSafe("]");
|
||||
// End of streamed response, empty chunk to signal client
|
||||
server->sendContent("");
|
||||
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Static variables for upload handling
|
||||
@@ -1264,6 +1295,40 @@ void CrossPointWebServer::handleRename() const {
|
||||
}
|
||||
}
|
||||
|
||||
// Counter for flow control pacing
|
||||
static uint8_t sendContentCounter = 0;
|
||||
|
||||
bool CrossPointWebServer::sendContentSafe(const char* content) const {
|
||||
if (!server || !server->client().connected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send the content
|
||||
server->sendContent(content);
|
||||
|
||||
// Flow control: give TCP stack time to transmit data and drain the send buffer
|
||||
// The ESP32 TCP buffer is limited and fills quickly when streaming many small chunks.
|
||||
// We use progressive delays:
|
||||
// - yield() after every send to allow WiFi processing
|
||||
// - delay(5ms) every send to allow buffer draining
|
||||
// - delay(50ms) every 10 sends to allow larger buffer flush
|
||||
yield();
|
||||
sendContentCounter++;
|
||||
|
||||
if (sendContentCounter >= 10) {
|
||||
sendContentCounter = 0;
|
||||
delay(50); // Longer pause every 10 sends for buffer catchup
|
||||
} else {
|
||||
delay(5); // Short pause each send
|
||||
}
|
||||
|
||||
return server->client().connected();
|
||||
}
|
||||
|
||||
bool CrossPointWebServer::sendContentSafe(const String& content) const {
|
||||
return sendContentSafe(content.c_str());
|
||||
}
|
||||
|
||||
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
|
||||
FsFile srcFile;
|
||||
FsFile destFile;
|
||||
|
||||
@@ -108,6 +108,11 @@ class CrossPointWebServer {
|
||||
bool copyFile(const String& srcPath, const String& destPath) const;
|
||||
bool copyFolder(const String& srcPath, const String& destPath) const;
|
||||
|
||||
// Helper for safe content sending with flow control
|
||||
// Returns false if client disconnected, true otherwise
|
||||
bool sendContentSafe(const char* content) const;
|
||||
bool sendContentSafe(const String& content) const;
|
||||
|
||||
// List management handlers
|
||||
void handleListGet() const;
|
||||
void handleListPost() const;
|
||||
|
||||
Reference in New Issue
Block a user