mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-08 08:37:38 +03:00
Compare commits
No commits in common. "9115fb43bcd50c296d7f1334450efe0cbbf182e3" and "d445eb0bb094f89c83adc6040a18681f9b98f3c2" have entirely different histories.
9115fb43bc
...
d445eb0bb0
14
README.md
14
README.md
@ -95,20 +95,6 @@ Connect your Xteink X4 to your computer via USB-C and run the following command.
|
|||||||
```sh
|
```sh
|
||||||
pio run --target upload
|
pio run --target upload
|
||||||
```
|
```
|
||||||
### Debugging
|
|
||||||
|
|
||||||
After flashing the new features, it’s recommended to capture detailed logs from the serial port.
|
|
||||||
|
|
||||||
First, make sure all required Python packages are installed:
|
|
||||||
|
|
||||||
```python
|
|
||||||
python3 -m pip install serial colorama matplotlib
|
|
||||||
```
|
|
||||||
after that run the script:
|
|
||||||
```sh
|
|
||||||
python3 scripts/debugging_monitor.py
|
|
||||||
```
|
|
||||||
This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS.
|
|
||||||
|
|
||||||
## Internals
|
## Internals
|
||||||
|
|
||||||
|
|||||||
@ -201,7 +201,7 @@ CrossPoint renders text using the following Unicode character blocks, enabling s
|
|||||||
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
|
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
|
||||||
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
|
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
|
||||||
|
|
||||||
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic, Greek and Farsi.
|
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -153,7 +153,7 @@ Click **File Manager** to access file management features.
|
|||||||
|
|
||||||
1. Click the **+ Add** button in the top-right corner
|
1. Click the **+ Add** button in the top-right corner
|
||||||
2. Select **New Folder** from the dropdown menu
|
2. Select **New Folder** from the dropdown menu
|
||||||
3. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
3. Enter a folder name (letters, numbers, underscores, and hyphens only)
|
||||||
4. Click **Create Folder**
|
4. Click **Create Folder**
|
||||||
|
|
||||||
This is useful for organizing your ebooks by genre, author, or series.
|
This is useful for organizing your ebooks by genre, author, or series.
|
||||||
|
|||||||
@ -1,19 +1,23 @@
|
|||||||
#include "EpdFontFamily.h"
|
#include "EpdFontFamily.h"
|
||||||
|
|
||||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
||||||
// Extract font style bits (ignore UNDERLINE bit for font selection)
|
if (style == BOLD && bold) {
|
||||||
const bool hasBold = (style & BOLD) != 0;
|
|
||||||
const bool hasItalic = (style & ITALIC) != 0;
|
|
||||||
|
|
||||||
if (hasBold && hasItalic) {
|
|
||||||
if (boldItalic) return boldItalic;
|
|
||||||
if (bold) return bold;
|
|
||||||
if (italic) return italic;
|
|
||||||
} else if (hasBold && bold) {
|
|
||||||
return bold;
|
return bold;
|
||||||
} else if (hasItalic && italic) {
|
}
|
||||||
|
if (style == ITALIC && italic) {
|
||||||
return italic;
|
return italic;
|
||||||
}
|
}
|
||||||
|
if (style == BOLD_ITALIC) {
|
||||||
|
if (boldItalic) {
|
||||||
|
return boldItalic;
|
||||||
|
}
|
||||||
|
if (bold) {
|
||||||
|
return bold;
|
||||||
|
}
|
||||||
|
if (italic) {
|
||||||
|
return italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return regular;
|
return regular;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
class EpdFontFamily {
|
class EpdFontFamily {
|
||||||
public:
|
public:
|
||||||
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4 };
|
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3 };
|
||||||
|
|
||||||
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||||
const EpdFont* boldItalic = nullptr)
|
const EpdFont* boldItalic = nullptr)
|
||||||
|
|||||||
@ -86,9 +86,8 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|||||||
tocNavItem = opfParser.tocNavPath;
|
tocNavItem = opfParser.tocNavPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!opfParser.cssFiles.empty()) {
|
// Copy CSS files to metadata
|
||||||
cssFiles = opfParser.cssFiles;
|
bookMetadata.cssFiles = opfParser.cssFiles;
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
||||||
return true;
|
return true;
|
||||||
@ -208,91 +207,66 @@ bool Epub::parseTocNavFile() const {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
|
bool Epub::parseCssFiles() {
|
||||||
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||||
bool Epub::loadCssRulesFromCache() const {
|
Serial.printf("[%lu] [EBP] Cannot parse CSS, cache not loaded\n", millis());
|
||||||
FsFile cssCacheFile;
|
return false;
|
||||||
if (SdMan.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
|
|
||||||
if (cssParser->loadFromCache(cssCacheFile)) {
|
|
||||||
cssCacheFile.close();
|
|
||||||
Serial.printf("[%lu] [EBP] Loaded CSS rules from cache\n", millis());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
cssCacheFile.close();
|
|
||||||
Serial.printf("[%lu] [EBP] CSS cache invalid, reparsing\n", millis());
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Epub::parseCssFiles() const {
|
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||||
|
cssParser.reset(new CssParser());
|
||||||
|
|
||||||
|
const auto& cssFiles = bookMetadataCache->coreMetadata.cssFiles;
|
||||||
if (cssFiles.empty()) {
|
if (cssFiles.empty()) {
|
||||||
Serial.printf("[%lu] [EBP] No CSS files to parse, but CssParser created for inline styles\n", millis());
|
Serial.printf("[%lu] [EBP] No CSS files to parse, but CssParser created for inline styles\n", millis());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to load from CSS cache first
|
for (const auto& cssPath : cssFiles) {
|
||||||
if (!loadCssRulesFromCache()) {
|
Serial.printf("[%lu] [EBP] Parsing CSS file: %s\n", millis(), cssPath.c_str());
|
||||||
// Cache miss - parse CSS files
|
|
||||||
for (const auto& cssPath : cssFiles) {
|
|
||||||
Serial.printf("[%lu] [EBP] Parsing CSS file: %s\n", millis(), cssPath.c_str());
|
|
||||||
|
|
||||||
// Extract CSS file to temp location
|
// Extract CSS file to temp location
|
||||||
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
||||||
FsFile tempCssFile;
|
FsFile tempCssFile;
|
||||||
if (!SdMan.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
if (!SdMan.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
||||||
Serial.printf("[%lu] [EBP] Could not create temp CSS file\n", millis());
|
Serial.printf("[%lu] [EBP] Could not create temp CSS file\n", millis());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
||||||
Serial.printf("[%lu] [EBP] Could not read CSS file: %s\n", millis(), cssPath.c_str());
|
Serial.printf("[%lu] [EBP] Could not read CSS file: %s\n", millis(), cssPath.c_str());
|
||||||
tempCssFile.close();
|
|
||||||
SdMan.remove(tmpCssPath.c_str());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tempCssFile.close();
|
|
||||||
|
|
||||||
// Parse the CSS file
|
|
||||||
if (!SdMan.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
|
||||||
Serial.printf("[%lu] [EBP] Could not open temp CSS file for reading\n", millis());
|
|
||||||
SdMan.remove(tmpCssPath.c_str());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cssParser->loadFromStream(tempCssFile);
|
|
||||||
tempCssFile.close();
|
tempCssFile.close();
|
||||||
SdMan.remove(tmpCssPath.c_str());
|
SdMan.remove(tmpCssPath.c_str());
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
tempCssFile.close();
|
||||||
|
|
||||||
// Save to cache for next time
|
// Parse the CSS file
|
||||||
FsFile cssCacheFile;
|
if (!SdMan.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
||||||
if (SdMan.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
|
Serial.printf("[%lu] [EBP] Could not open temp CSS file for reading\n", millis());
|
||||||
cssParser->saveToCache(cssCacheFile);
|
SdMan.remove(tmpCssPath.c_str());
|
||||||
cssCacheFile.close();
|
continue;
|
||||||
}
|
}
|
||||||
|
cssParser->loadFromStream(tempCssFile);
|
||||||
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files\n", millis(), cssParser->ruleCount(),
|
tempCssFile.close();
|
||||||
cssFiles.size());
|
SdMan.remove(tmpCssPath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files\n", millis(), cssParser->ruleCount(),
|
||||||
|
cssFiles.size());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// load in the meta data for the epub file
|
// load in the meta data for the epub file
|
||||||
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
bool Epub::load(const bool buildIfMissing) {
|
||||||
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
||||||
|
|
||||||
// Initialize spine/TOC cache
|
// Initialize spine/TOC cache
|
||||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||||
// Always create CssParser - needed for inline style parsing even without CSS files
|
|
||||||
cssParser.reset(new CssParser());
|
|
||||||
|
|
||||||
// Try to load existing cache first
|
// Try to load existing cache first
|
||||||
if (bookMetadataCache->load()) {
|
if (bookMetadataCache->load()) {
|
||||||
if (!skipLoadingCss && !loadCssRulesFromCache()) {
|
// Parse CSS files from loaded cache
|
||||||
Serial.printf("[%lu] [EBP] Warning: CSS rules cache not found, attempting to parse CSS files\n", millis());
|
parseCssFiles();
|
||||||
// to get CSS file list
|
|
||||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
|
||||||
Serial.printf("[%lu] [EBP] Could not parse content.opf from cached bookMetadata for CSS files\n", millis());
|
|
||||||
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
|
||||||
}
|
|
||||||
parseCssFiles();
|
|
||||||
}
|
|
||||||
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -389,10 +363,8 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipLoadingCss) {
|
// Parse CSS files after cache reload
|
||||||
// Parse CSS files after cache reload
|
parseCssFiles();
|
||||||
parseCssFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -27,16 +27,12 @@ class Epub {
|
|||||||
std::unique_ptr<BookMetadataCache> bookMetadataCache;
|
std::unique_ptr<BookMetadataCache> bookMetadataCache;
|
||||||
// CSS parser for styling
|
// CSS parser for styling
|
||||||
std::unique_ptr<CssParser> cssParser;
|
std::unique_ptr<CssParser> cssParser;
|
||||||
// CSS files
|
|
||||||
std::vector<std::string> cssFiles;
|
|
||||||
|
|
||||||
bool findContentOpfFile(std::string* contentOpfFile) const;
|
bool findContentOpfFile(std::string* contentOpfFile) const;
|
||||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
||||||
bool parseTocNcxFile() const;
|
bool parseTocNcxFile() const;
|
||||||
bool parseTocNavFile() const;
|
bool parseTocNavFile() const;
|
||||||
void parseCssFiles() const;
|
bool parseCssFiles();
|
||||||
std::string getCssRulesCache() const;
|
|
||||||
bool loadCssRulesFromCache() const;
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||||
@ -45,7 +41,7 @@ class Epub {
|
|||||||
}
|
}
|
||||||
~Epub() = default;
|
~Epub() = default;
|
||||||
std::string& getBasePath() { return contentBasePath; }
|
std::string& getBasePath() { return contentBasePath; }
|
||||||
bool load(bool buildIfMissing = true, bool skipLoadingCss = false);
|
bool load(bool buildIfMissing = true);
|
||||||
bool clearCache() const;
|
bool clearCache() const;
|
||||||
void setupCacheDir() const;
|
void setupCacheDir() const;
|
||||||
const std::string& getCachePath() const;
|
const std::string& getCachePath() const;
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
#include "FsHelpers.h"
|
#include "FsHelpers.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t BOOK_CACHE_VERSION = 5;
|
constexpr uint8_t BOOK_CACHE_VERSION = 6;
|
||||||
constexpr char bookBinFile[] = "/book.bin";
|
constexpr char bookBinFile[] = "/book.bin";
|
||||||
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
||||||
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
|
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
|
||||||
@ -115,9 +115,14 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
|||||||
|
|
||||||
constexpr uint32_t headerASize =
|
constexpr uint32_t headerASize =
|
||||||
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount);
|
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount);
|
||||||
|
// Calculate CSS files size: count + each string (length + data)
|
||||||
|
uint32_t cssFilesSize = sizeof(uint16_t); // count
|
||||||
|
for (const auto& css : metadata.cssFiles) {
|
||||||
|
cssFilesSize += sizeof(uint32_t) + css.size();
|
||||||
|
}
|
||||||
const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.language.size() +
|
const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.language.size() +
|
||||||
metadata.coverItemHref.size() + metadata.textReferenceHref.size() +
|
metadata.coverItemHref.size() + metadata.textReferenceHref.size() +
|
||||||
sizeof(uint32_t) * 5;
|
sizeof(uint32_t) * 5 + cssFilesSize;
|
||||||
const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount;
|
const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount;
|
||||||
const uint32_t lutOffset = headerASize + metadataSize;
|
const uint32_t lutOffset = headerASize + metadataSize;
|
||||||
|
|
||||||
@ -132,6 +137,11 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
|||||||
serialization::writeString(bookFile, metadata.language);
|
serialization::writeString(bookFile, metadata.language);
|
||||||
serialization::writeString(bookFile, metadata.coverItemHref);
|
serialization::writeString(bookFile, metadata.coverItemHref);
|
||||||
serialization::writeString(bookFile, metadata.textReferenceHref);
|
serialization::writeString(bookFile, metadata.textReferenceHref);
|
||||||
|
// CSS files
|
||||||
|
serialization::writePod(bookFile, static_cast<uint16_t>(metadata.cssFiles.size()));
|
||||||
|
for (const auto& css : metadata.cssFiles) {
|
||||||
|
serialization::writeString(bookFile, css);
|
||||||
|
}
|
||||||
|
|
||||||
// Loop through spine entries, writing LUT positions
|
// Loop through spine entries, writing LUT positions
|
||||||
spineFile.seek(0);
|
spineFile.seek(0);
|
||||||
@ -385,6 +395,16 @@ bool BookMetadataCache::load() {
|
|||||||
serialization::readString(bookFile, coreMetadata.language);
|
serialization::readString(bookFile, coreMetadata.language);
|
||||||
serialization::readString(bookFile, coreMetadata.coverItemHref);
|
serialization::readString(bookFile, coreMetadata.coverItemHref);
|
||||||
serialization::readString(bookFile, coreMetadata.textReferenceHref);
|
serialization::readString(bookFile, coreMetadata.textReferenceHref);
|
||||||
|
// CSS files
|
||||||
|
uint16_t cssCount;
|
||||||
|
serialization::readPod(bookFile, cssCount);
|
||||||
|
coreMetadata.cssFiles.clear();
|
||||||
|
coreMetadata.cssFiles.reserve(cssCount);
|
||||||
|
for (uint16_t i = 0; i < cssCount; i++) {
|
||||||
|
std::string cssPath;
|
||||||
|
serialization::readString(bookFile, cssPath);
|
||||||
|
coreMetadata.cssFiles.push_back(std::move(cssPath));
|
||||||
|
}
|
||||||
|
|
||||||
loaded = true;
|
loaded = true;
|
||||||
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
|
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class BookMetadataCache {
|
|||||||
std::string language;
|
std::string language;
|
||||||
std::string coverItemHref;
|
std::string coverItemHref;
|
||||||
std::string textReferenceHref;
|
std::string textReferenceHref;
|
||||||
|
std::vector<std::string> cssFiles;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SpineEntry {
|
struct SpineEntry {
|
||||||
|
|||||||
@ -19,6 +19,23 @@ namespace {
|
|||||||
constexpr char SOFT_HYPHEN_UTF8[] = "\xC2\xAD";
|
constexpr char SOFT_HYPHEN_UTF8[] = "\xC2\xAD";
|
||||||
constexpr size_t SOFT_HYPHEN_BYTES = 2;
|
constexpr size_t SOFT_HYPHEN_BYTES = 2;
|
||||||
|
|
||||||
|
// Check if a character is punctuation that should attach to the previous word
|
||||||
|
// (no space before it). Includes sentence punctuation and closing quotes.
|
||||||
|
// Excludes brackets/parens to avoid false positives with decorative patterns like "[ 1 ]".
|
||||||
|
bool isAttachingPunctuation(const char c) {
|
||||||
|
return c == '.' || c == ',' || c == '!' || c == '?' || c == ';' || c == ':' || c == '"' || c == '\'';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a word consists entirely of punctuation that should attach to the previous word
|
||||||
|
bool isAttachingPunctuationWord(const std::string& word) {
|
||||||
|
if (word.empty()) return false;
|
||||||
|
// Check if word starts with attaching punctuation and is short (to avoid false positives)
|
||||||
|
if (isAttachingPunctuation(word[0]) && word.size() <= 3) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; }
|
bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; }
|
||||||
|
|
||||||
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
|
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
|
||||||
@ -49,15 +66,12 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void ParsedText::addWord(std::string word, const EpdFontFamily::Style style, const bool underline) {
|
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline) {
|
||||||
if (word.empty()) return;
|
if (word.empty()) return;
|
||||||
|
|
||||||
words.push_back(std::move(word));
|
words.push_back(std::move(word));
|
||||||
EpdFontFamily::Style combinedStyle = style;
|
wordStyles.push_back(fontStyle);
|
||||||
if (underline) {
|
wordUnderlines.push_back(underline);
|
||||||
combinedStyle = static_cast<EpdFontFamily::Style>(combinedStyle | EpdFontFamily::UNDERLINE);
|
|
||||||
}
|
|
||||||
wordStyles.push_back(combinedStyle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consumes data to minimize memory usage
|
// Consumes data to minimize memory usage
|
||||||
@ -98,7 +112,8 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
|||||||
auto wordStylesIt = wordStyles.begin();
|
auto wordStylesIt = wordStyles.begin();
|
||||||
|
|
||||||
while (wordsIt != words.end()) {
|
while (wordsIt != words.end()) {
|
||||||
wordWidths.push_back(measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt));
|
uint16_t width = measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt);
|
||||||
|
wordWidths.push_back(width);
|
||||||
|
|
||||||
std::advance(wordsIt, 1);
|
std::advance(wordsIt, 1);
|
||||||
std::advance(wordStylesIt, 1);
|
std::advance(wordStylesIt, 1);
|
||||||
@ -114,11 +129,10 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
||||||
const int firstLineIndent =
|
const int firstLineIndent = blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
||||||
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
(style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN)
|
||||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
? blockStyle.textIndent
|
||||||
? blockStyle.textIndent
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Ensure any word that would overflow even as the first entry on a line is split using fallback hyphenation.
|
// Ensure any word that would overflow even as the first entry on a line is split using fallback hyphenation.
|
||||||
for (size_t i = 0; i < wordWidths.size(); ++i) {
|
for (size_t i = 0; i < wordWidths.size(); ++i) {
|
||||||
@ -219,7 +233,7 @@ void ParsedText::applyParagraphIndent() {
|
|||||||
if (blockStyle.textIndentDefined) {
|
if (blockStyle.textIndentDefined) {
|
||||||
// CSS text-indent is explicitly set (even if 0) - don't use fallback EmSpace
|
// CSS text-indent is explicitly set (even if 0) - don't use fallback EmSpace
|
||||||
// The actual indent positioning is handled in extractLine()
|
// The actual indent positioning is handled in extractLine()
|
||||||
} else if (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) {
|
} else if (style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) {
|
||||||
// No CSS text-indent defined - use EmSpace fallback for visual indent
|
// No CSS text-indent defined - use EmSpace fallback for visual indent
|
||||||
words.front().insert(0, "\xe2\x80\x83");
|
words.front().insert(0, "\xe2\x80\x83");
|
||||||
}
|
}
|
||||||
@ -230,11 +244,10 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
|||||||
const int pageWidth, const int spaceWidth,
|
const int pageWidth, const int spaceWidth,
|
||||||
std::vector<uint16_t>& wordWidths) {
|
std::vector<uint16_t>& wordWidths) {
|
||||||
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
||||||
const int firstLineIndent =
|
const int firstLineIndent = blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
||||||
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
(style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN)
|
||||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
? blockStyle.textIndent
|
||||||
? blockStyle.textIndent
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
std::vector<size_t> lineBreakIndices;
|
std::vector<size_t> lineBreakIndices;
|
||||||
size_t currentIndex = 0;
|
size_t currentIndex = 0;
|
||||||
@ -368,16 +381,25 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
|
|
||||||
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
||||||
const bool isFirstLine = breakIndex == 0;
|
const bool isFirstLine = breakIndex == 0;
|
||||||
const int firstLineIndent =
|
const int firstLineIndent = isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
||||||
isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
(style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN)
|
||||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
? blockStyle.textIndent
|
||||||
? blockStyle.textIndent
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Calculate total word width for this line
|
// Calculate total word width for this line and count actual word gaps
|
||||||
|
// (punctuation that attaches to previous word doesn't count as a gap)
|
||||||
|
// Note: words list starts at the beginning because previous lines were spliced out
|
||||||
int lineWordWidthSum = 0;
|
int lineWordWidthSum = 0;
|
||||||
for (size_t i = lastBreakAt; i < lineBreak; i++) {
|
size_t actualGapCount = 0;
|
||||||
lineWordWidthSum += wordWidths[i];
|
auto countWordIt = words.begin();
|
||||||
|
|
||||||
|
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||||
|
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
|
||||||
|
// Count gaps: each word after the first creates a gap, unless it's attaching punctuation
|
||||||
|
if (wordIdx > 0 && !isAttachingPunctuationWord(*countWordIt)) {
|
||||||
|
actualGapCount++;
|
||||||
|
}
|
||||||
|
++countWordIt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate spacing (account for indent reducing effective page width on first line)
|
// Calculate spacing (account for indent reducing effective page width on first line)
|
||||||
@ -387,37 +409,54 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
int spacing = spaceWidth;
|
int spacing = spaceWidth;
|
||||||
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
|
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
|
||||||
|
|
||||||
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && lineWordCount >= 2) {
|
// For justified text, calculate spacing based on actual gap count
|
||||||
spacing = spareSpace / (lineWordCount - 1);
|
if (style == TextBlock::JUSTIFIED && !isLastLine && actualGapCount >= 1) {
|
||||||
|
spacing = spareSpace / static_cast<int>(actualGapCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate initial x position (first line starts at indent for left/justified text)
|
// Calculate initial x position (first line starts at indent for left/justified text)
|
||||||
auto xpos = static_cast<uint16_t>(firstLineIndent);
|
auto xpos = static_cast<uint16_t>(firstLineIndent);
|
||||||
if (blockStyle.alignment == CssTextAlign::Right) {
|
if (style == TextBlock::RIGHT_ALIGN) {
|
||||||
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
|
xpos = spareSpace - static_cast<int>(actualGapCount) * spaceWidth;
|
||||||
} else if (blockStyle.alignment == CssTextAlign::Center) {
|
} else if (style == TextBlock::CENTER_ALIGN) {
|
||||||
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
|
xpos = (spareSpace - static_cast<int>(actualGapCount) * spaceWidth) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-calculate X positions for words
|
// Pre-calculate X positions for words
|
||||||
|
// Punctuation that attaches to the previous word doesn't get space before it
|
||||||
|
// Note: words list starts at the beginning because previous lines were spliced out
|
||||||
std::list<uint16_t> lineXPos;
|
std::list<uint16_t> lineXPos;
|
||||||
for (size_t i = lastBreakAt; i < lineBreak; i++) {
|
auto wordIt = words.begin();
|
||||||
const uint16_t currentWordWidth = wordWidths[i];
|
|
||||||
|
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||||
|
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
|
||||||
|
|
||||||
lineXPos.push_back(xpos);
|
lineXPos.push_back(xpos);
|
||||||
xpos += currentWordWidth + spacing;
|
|
||||||
|
// Add spacing after this word, unless the next word is attaching punctuation
|
||||||
|
auto nextWordIt = wordIt;
|
||||||
|
++nextWordIt;
|
||||||
|
const bool nextIsAttachingPunctuation = wordIdx + 1 < lineWordCount && isAttachingPunctuationWord(*nextWordIt);
|
||||||
|
|
||||||
|
xpos += currentWordWidth + (nextIsAttachingPunctuation ? 0 : spacing);
|
||||||
|
++wordIt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterators always start at the beginning as we are moving content with splice below
|
// Iterators always start at the beginning as we are moving content with splice below
|
||||||
auto wordEndIt = words.begin();
|
auto wordEndIt = words.begin();
|
||||||
auto wordStyleEndIt = wordStyles.begin();
|
auto wordStyleEndIt = wordStyles.begin();
|
||||||
|
auto wordUnderlineEndIt = wordUnderlines.begin();
|
||||||
std::advance(wordEndIt, lineWordCount);
|
std::advance(wordEndIt, lineWordCount);
|
||||||
std::advance(wordStyleEndIt, lineWordCount);
|
std::advance(wordStyleEndIt, lineWordCount);
|
||||||
|
std::advance(wordUnderlineEndIt, lineWordCount);
|
||||||
|
|
||||||
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
||||||
std::list<std::string> lineWords;
|
std::list<std::string> lineWords;
|
||||||
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
|
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
|
||||||
std::list<EpdFontFamily::Style> lineWordStyles;
|
std::list<EpdFontFamily::Style> lineWordStyles;
|
||||||
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
||||||
|
std::list<bool> lineWordUnderlines;
|
||||||
|
lineWordUnderlines.splice(lineWordUnderlines.begin(), wordUnderlines, wordUnderlines.begin(), wordUnderlineEndIt);
|
||||||
|
|
||||||
for (auto& word : lineWords) {
|
for (auto& word : lineWords) {
|
||||||
if (containsSoftHyphen(word)) {
|
if (containsSoftHyphen(word)) {
|
||||||
@ -425,6 +464,6 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processLine(
|
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style,
|
||||||
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle));
|
blockStyle, std::move(lineWordUnderlines)));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ class GfxRenderer;
|
|||||||
class ParsedText {
|
class ParsedText {
|
||||||
std::list<std::string> words;
|
std::list<std::string> words;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::list<EpdFontFamily::Style> wordStyles;
|
||||||
|
std::list<bool> wordUnderlines; // Track underline per word
|
||||||
|
TextBlock::Style style;
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
bool extraParagraphSpacing;
|
bool extraParagraphSpacing;
|
||||||
bool hyphenationEnabled;
|
bool hyphenationEnabled;
|
||||||
@ -33,14 +35,19 @@ class ParsedText {
|
|||||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
|
explicit ParsedText(const TextBlock::Style style, const bool extraParagraphSpacing,
|
||||||
const BlockStyle& blockStyle = BlockStyle())
|
const bool hyphenationEnabled = false, const BlockStyle& blockStyle = BlockStyle())
|
||||||
: blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
|
: style(style),
|
||||||
|
blockStyle(blockStyle),
|
||||||
|
extraParagraphSpacing(extraParagraphSpacing),
|
||||||
|
hyphenationEnabled(hyphenationEnabled) {}
|
||||||
~ParsedText() = default;
|
~ParsedText() = default;
|
||||||
|
|
||||||
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false);
|
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false);
|
||||||
|
void setStyle(const TextBlock::Style style) { this->style = style; }
|
||||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||||
BlockStyle& getBlockStyle() { return blockStyle; }
|
TextBlock::Style getStyle() const { return style; }
|
||||||
|
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||||
size_t size() const { return words.size(); }
|
size_t size() const { return words.size(); }
|
||||||
bool isEmpty() const { return words.empty(); }
|
bool isEmpty() const { return words.empty(); }
|
||||||
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
|
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
|
||||||
|
|||||||
@ -123,7 +123,9 @@ bool Section::clearCache() const {
|
|||||||
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||||
const std::function<void()>& popupFn) {
|
const std::function<void()>& progressSetupFn,
|
||||||
|
const std::function<void(int)>& progressFn) {
|
||||||
|
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
||||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||||
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||||
|
|
||||||
@ -169,6 +171,11 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
|
|
||||||
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
|
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
|
||||||
|
|
||||||
|
// Only show progress bar for larger chapters where rendering overhead is worth it
|
||||||
|
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
|
||||||
|
progressSetupFn();
|
||||||
|
}
|
||||||
|
|
||||||
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
|
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -179,7 +186,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
ChapterHtmlSlimParser visitor(
|
ChapterHtmlSlimParser visitor(
|
||||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled,
|
viewportHeight, hyphenationEnabled,
|
||||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn,
|
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, progressFn,
|
||||||
epub->getCssParser());
|
epub->getCssParser());
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
success = visitor.parseAndBuildPages();
|
success = visitor.parseAndBuildPages();
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class Section {
|
|||||||
bool clearCache() const;
|
bool clearCache() const;
|
||||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
|
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
|
||||||
const std::function<void()>& popupFn = nullptr);
|
const std::function<void()>& progressSetupFn = nullptr,
|
||||||
|
const std::function<void(int)>& progressFn = nullptr);
|
||||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,89 +2,26 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
#include "Epub/css/CssStyle.h"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlockStyle - Block-level styling properties
|
* BlockStyle - Block-level CSS properties for paragraphs
|
||||||
|
*
|
||||||
|
* Used to track margin/padding spacing and text indentation for block elements.
|
||||||
|
* Padding is treated similarly to margins for rendering purposes.
|
||||||
*/
|
*/
|
||||||
struct BlockStyle {
|
struct BlockStyle {
|
||||||
CssTextAlign alignment = CssTextAlign::Justify;
|
int16_t marginTop = 0; // pixels
|
||||||
|
int16_t marginBottom = 0; // pixels
|
||||||
// Spacing (in pixels)
|
int16_t marginLeft = 0; // pixels
|
||||||
int16_t marginTop = 0;
|
int16_t marginRight = 0; // pixels
|
||||||
int16_t marginBottom = 0;
|
int16_t paddingTop = 0; // pixels (treated same as margin)
|
||||||
int16_t marginLeft = 0;
|
int16_t paddingBottom = 0; // pixels (treated same as margin)
|
||||||
int16_t marginRight = 0;
|
int16_t paddingLeft = 0; // pixels (treated same as margin)
|
||||||
int16_t paddingTop = 0; // treated same as margin for rendering
|
int16_t paddingRight = 0; // pixels (treated same as margin)
|
||||||
int16_t paddingBottom = 0; // treated same as margin for rendering
|
int16_t textIndent = 0; // pixels
|
||||||
int16_t paddingLeft = 0; // treated same as margin for rendering
|
|
||||||
int16_t paddingRight = 0; // treated same as margin for rendering
|
|
||||||
int16_t textIndent = 0;
|
|
||||||
bool textIndentDefined = false; // true if text-indent was explicitly set in CSS
|
bool textIndentDefined = false; // true if text-indent was explicitly set in CSS
|
||||||
bool textAlignDefined = false; // true if text-align was explicitly set in CSS
|
|
||||||
|
|
||||||
// Combined horizontal insets (margin + padding)
|
// Combined horizontal insets (margin + padding)
|
||||||
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
|
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
|
||||||
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
|
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
|
||||||
[[nodiscard]] int16_t totalHorizontalInset() const { return leftInset() + rightInset(); }
|
[[nodiscard]] int16_t totalHorizontalInset() const { return leftInset() + rightInset(); }
|
||||||
|
|
||||||
// Combine with another block style. Useful for parent -> child styles, where the child style should be
|
|
||||||
// applied on top of the parent's style to get the combined style.
|
|
||||||
BlockStyle getCombinedBlockStyle(const BlockStyle& child) const {
|
|
||||||
BlockStyle combinedBlockStyle;
|
|
||||||
|
|
||||||
combinedBlockStyle.marginTop = static_cast<int16_t>(child.marginTop + marginTop);
|
|
||||||
combinedBlockStyle.marginBottom = static_cast<int16_t>(child.marginBottom + marginBottom);
|
|
||||||
combinedBlockStyle.marginLeft = static_cast<int16_t>(child.marginLeft + marginLeft);
|
|
||||||
combinedBlockStyle.marginRight = static_cast<int16_t>(child.marginRight + marginRight);
|
|
||||||
|
|
||||||
combinedBlockStyle.paddingTop = static_cast<int16_t>(child.paddingTop + paddingTop);
|
|
||||||
combinedBlockStyle.paddingBottom = static_cast<int16_t>(child.paddingBottom + paddingBottom);
|
|
||||||
combinedBlockStyle.paddingLeft = static_cast<int16_t>(child.paddingLeft + paddingLeft);
|
|
||||||
combinedBlockStyle.paddingRight = static_cast<int16_t>(child.paddingRight + paddingRight);
|
|
||||||
// Text indent: use child's if defined
|
|
||||||
if (child.textIndentDefined) {
|
|
||||||
combinedBlockStyle.textIndent = child.textIndent;
|
|
||||||
combinedBlockStyle.textIndentDefined = true;
|
|
||||||
} else {
|
|
||||||
combinedBlockStyle.textIndent = textIndent;
|
|
||||||
combinedBlockStyle.textIndentDefined = textIndentDefined;
|
|
||||||
}
|
|
||||||
// Text align: use child's if defined
|
|
||||||
if (child.textAlignDefined) {
|
|
||||||
combinedBlockStyle.alignment = child.alignment;
|
|
||||||
combinedBlockStyle.textAlignDefined = true;
|
|
||||||
} else {
|
|
||||||
combinedBlockStyle.alignment = alignment;
|
|
||||||
combinedBlockStyle.textAlignDefined = textAlignDefined;
|
|
||||||
}
|
|
||||||
return combinedBlockStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a BlockStyle from CSS style properties, resolving CssLength values to pixels
|
|
||||||
// emSize is the current font line height, used for em/rem unit conversion
|
|
||||||
// paragraphAlignment is the user's paragraphAlignment setting preference
|
|
||||||
static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment) {
|
|
||||||
BlockStyle blockStyle;
|
|
||||||
// Resolve all CssLength values to pixels using the current font's em size
|
|
||||||
blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize);
|
|
||||||
blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize);
|
|
||||||
blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize);
|
|
||||||
blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize);
|
|
||||||
|
|
||||||
blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize);
|
|
||||||
blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize);
|
|
||||||
blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize);
|
|
||||||
blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize);
|
|
||||||
|
|
||||||
blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize);
|
|
||||||
blockStyle.textIndentDefined = cssStyle.hasTextIndent();
|
|
||||||
blockStyle.textAlignDefined = cssStyle.hasTextAlign();
|
|
||||||
if (blockStyle.textAlignDefined) {
|
|
||||||
blockStyle.alignment = cssStyle.textAlign;
|
|
||||||
} else {
|
|
||||||
blockStyle.alignment = paragraphAlignment;
|
|
||||||
}
|
|
||||||
return blockStyle;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,14 +14,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
auto wordIt = words.begin();
|
auto wordIt = words.begin();
|
||||||
auto wordStylesIt = wordStyles.begin();
|
auto wordStylesIt = wordStyles.begin();
|
||||||
auto wordXposIt = wordXpos.begin();
|
auto wordXposIt = wordXpos.begin();
|
||||||
|
auto wordUnderlineIt = wordUnderlines.begin();
|
||||||
for (size_t i = 0; i < words.size(); i++) {
|
for (size_t i = 0; i < words.size(); i++) {
|
||||||
const int wordX = *wordXposIt + x;
|
const int wordX = *wordXposIt + x;
|
||||||
const EpdFontFamily::Style currentStyle = *wordStylesIt;
|
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, *wordStylesIt);
|
||||||
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle);
|
|
||||||
|
|
||||||
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
// Draw underline if word is underlined
|
||||||
|
if (wordUnderlineIt != wordUnderlines.end() && *wordUnderlineIt) {
|
||||||
const std::string& w = *wordIt;
|
const std::string& w = *wordIt;
|
||||||
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
|
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), *wordStylesIt);
|
||||||
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
||||||
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
||||||
|
|
||||||
@ -32,8 +33,8 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
||||||
static_cast<uint8_t>(w[2]) == 0x83) {
|
static_cast<uint8_t>(w[2]) == 0x83) {
|
||||||
const char* visiblePtr = w.c_str() + 3;
|
const char* visiblePtr = w.c_str() + 3;
|
||||||
const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str());
|
const int prefixWidth = renderer.getIndentWidth(fontId, std::string("\xe2\x80\x83").c_str());
|
||||||
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, *wordStylesIt);
|
||||||
startX = wordX + prefixWidth;
|
startX = wordX + prefixWidth;
|
||||||
underlineWidth = visibleWidth;
|
underlineWidth = visibleWidth;
|
||||||
}
|
}
|
||||||
@ -44,6 +45,9 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
std::advance(wordIt, 1);
|
std::advance(wordIt, 1);
|
||||||
std::advance(wordStylesIt, 1);
|
std::advance(wordStylesIt, 1);
|
||||||
std::advance(wordXposIt, 1);
|
std::advance(wordXposIt, 1);
|
||||||
|
if (wordUnderlineIt != wordUnderlines.end()) {
|
||||||
|
std::advance(wordUnderlineIt, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,9 +64,29 @@ bool TextBlock::serialize(FsFile& file) const {
|
|||||||
for (auto x : wordXpos) serialization::writePod(file, x);
|
for (auto x : wordXpos) serialization::writePod(file, x);
|
||||||
for (auto s : wordStyles) serialization::writePod(file, s);
|
for (auto s : wordStyles) serialization::writePod(file, s);
|
||||||
|
|
||||||
// Style (alignment + margins/padding/indent)
|
// Underline flags (packed as bytes, 8 words per byte)
|
||||||
serialization::writePod(file, blockStyle.alignment);
|
uint8_t underlineByte = 0;
|
||||||
serialization::writePod(file, blockStyle.textAlignDefined);
|
int bitIndex = 0;
|
||||||
|
auto underlineIt = wordUnderlines.begin();
|
||||||
|
for (size_t i = 0; i < words.size(); i++) {
|
||||||
|
if (underlineIt != wordUnderlines.end() && *underlineIt) {
|
||||||
|
underlineByte |= 1 << bitIndex;
|
||||||
|
}
|
||||||
|
bitIndex++;
|
||||||
|
if (bitIndex == 8 || i == words.size() - 1) {
|
||||||
|
serialization::writePod(file, underlineByte);
|
||||||
|
underlineByte = 0;
|
||||||
|
bitIndex = 0;
|
||||||
|
}
|
||||||
|
if (underlineIt != wordUnderlines.end()) {
|
||||||
|
++underlineIt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block style (alignment)
|
||||||
|
serialization::writePod(file, style);
|
||||||
|
|
||||||
|
// Block style (margins/padding/indent)
|
||||||
serialization::writePod(file, blockStyle.marginTop);
|
serialization::writePod(file, blockStyle.marginTop);
|
||||||
serialization::writePod(file, blockStyle.marginBottom);
|
serialization::writePod(file, blockStyle.marginBottom);
|
||||||
serialization::writePod(file, blockStyle.marginLeft);
|
serialization::writePod(file, blockStyle.marginLeft);
|
||||||
@ -82,6 +106,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
|||||||
std::list<std::string> words;
|
std::list<std::string> words;
|
||||||
std::list<uint16_t> wordXpos;
|
std::list<uint16_t> wordXpos;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::list<EpdFontFamily::Style> wordStyles;
|
||||||
|
std::list<bool> wordUnderlines;
|
||||||
|
Style style;
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
|
|
||||||
// Word count
|
// Word count
|
||||||
@ -101,9 +127,23 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
|||||||
for (auto& x : wordXpos) serialization::readPod(file, x);
|
for (auto& x : wordXpos) serialization::readPod(file, x);
|
||||||
for (auto& s : wordStyles) serialization::readPod(file, s);
|
for (auto& s : wordStyles) serialization::readPod(file, s);
|
||||||
|
|
||||||
// Style (alignment + margins/padding/indent)
|
// Underline flags (packed as bytes, 8 words per byte)
|
||||||
serialization::readPod(file, blockStyle.alignment);
|
wordUnderlines.resize(wc, false);
|
||||||
serialization::readPod(file, blockStyle.textAlignDefined);
|
auto underlineIt = wordUnderlines.begin();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block style (alignment)
|
||||||
|
serialization::readPod(file, style);
|
||||||
|
|
||||||
|
// Block style (margins/padding/indent)
|
||||||
serialization::readPod(file, blockStyle.marginTop);
|
serialization::readPod(file, blockStyle.marginTop);
|
||||||
serialization::readPod(file, blockStyle.marginBottom);
|
serialization::readPod(file, blockStyle.marginBottom);
|
||||||
serialization::readPod(file, blockStyle.marginLeft);
|
serialization::readPod(file, blockStyle.marginLeft);
|
||||||
@ -115,6 +155,6 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
|||||||
serialization::readPod(file, blockStyle.textIndent);
|
serialization::readPod(file, blockStyle.textIndent);
|
||||||
serialization::readPod(file, blockStyle.textIndentDefined);
|
serialization::readPod(file, blockStyle.textIndentDefined);
|
||||||
|
|
||||||
return std::unique_ptr<TextBlock>(
|
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style,
|
||||||
new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), blockStyle));
|
blockStyle, std::move(wordUnderlines)));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,21 +11,41 @@
|
|||||||
|
|
||||||
// Represents a line of text on a page
|
// Represents a line of text on a page
|
||||||
class TextBlock final : public Block {
|
class TextBlock final : public Block {
|
||||||
|
public:
|
||||||
|
enum Style : uint8_t {
|
||||||
|
JUSTIFIED = 0,
|
||||||
|
LEFT_ALIGN = 1,
|
||||||
|
CENTER_ALIGN = 2,
|
||||||
|
RIGHT_ALIGN = 3,
|
||||||
|
};
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::list<std::string> words;
|
std::list<std::string> words;
|
||||||
std::list<uint16_t> wordXpos;
|
std::list<uint16_t> wordXpos;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::list<EpdFontFamily::Style> wordStyles;
|
||||||
|
std::list<bool> wordUnderlines; // Track underline per word
|
||||||
|
Style style;
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
||||||
std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
|
std::list<EpdFontFamily::Style> word_styles, const Style style,
|
||||||
|
const BlockStyle& blockStyle = BlockStyle(), std::list<bool> word_underlines = std::list<bool>())
|
||||||
: words(std::move(words)),
|
: words(std::move(words)),
|
||||||
wordXpos(std::move(word_xpos)),
|
wordXpos(std::move(word_xpos)),
|
||||||
wordStyles(std::move(word_styles)),
|
wordStyles(std::move(word_styles)),
|
||||||
blockStyle(blockStyle) {}
|
wordUnderlines(std::move(word_underlines)),
|
||||||
|
style(style),
|
||||||
|
blockStyle(blockStyle) {
|
||||||
|
// Ensure underlines list matches words list size
|
||||||
|
while (this->wordUnderlines.size() < this->words.size()) {
|
||||||
|
this->wordUnderlines.push_back(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
~TextBlock() override = default;
|
~TextBlock() override = default;
|
||||||
|
void setStyle(const Style style) { this->style = style; }
|
||||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||||
|
Style getStyle() const { return style; }
|
||||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||||
bool isEmpty() override { return words.empty(); }
|
bool isEmpty() override { return words.empty(); }
|
||||||
void layout(GfxRenderer& renderer) override {};
|
void layout(GfxRenderer& renderer) override {};
|
||||||
|
|||||||
@ -204,15 +204,15 @@ std::vector<std::string> CssParser::splitWhitespace(const std::string& s) {
|
|||||||
|
|
||||||
// Property value interpreters
|
// Property value interpreters
|
||||||
|
|
||||||
CssTextAlign CssParser::interpretAlignment(const std::string& val) {
|
TextAlign CssParser::interpretAlignment(const std::string& val) {
|
||||||
const std::string v = normalized(val);
|
const std::string v = normalized(val);
|
||||||
|
|
||||||
if (v == "left" || v == "start") return CssTextAlign::Left;
|
if (v == "left" || v == "start") return TextAlign::Left;
|
||||||
if (v == "right" || v == "end") return CssTextAlign::Right;
|
if (v == "right" || v == "end") return TextAlign::Right;
|
||||||
if (v == "center") return CssTextAlign::Center;
|
if (v == "center") return TextAlign::Center;
|
||||||
if (v == "justify") return CssTextAlign::Justify;
|
if (v == "justify") return TextAlign::Justify;
|
||||||
|
|
||||||
return CssTextAlign::Left;
|
return TextAlign::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
CssFontStyle CssParser::interpretFontStyle(const std::string& val) {
|
CssFontStyle CssParser::interpretFontStyle(const std::string& val) {
|
||||||
@ -352,8 +352,11 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
|||||||
|
|
||||||
// Match property and set value
|
// Match property and set value
|
||||||
if (propName == "text-align") {
|
if (propName == "text-align") {
|
||||||
style.textAlign = interpretAlignment(propValue);
|
const TextAlign align = interpretAlignment(propValue);
|
||||||
style.defined.textAlign = 1;
|
if (align != TextAlign::None) {
|
||||||
|
style.alignment = align;
|
||||||
|
style.defined.alignment = 1;
|
||||||
|
}
|
||||||
} else if (propName == "font-style") {
|
} else if (propName == "font-style") {
|
||||||
style.fontStyle = interpretFontStyle(propValue);
|
style.fontStyle = interpretFontStyle(propValue);
|
||||||
style.defined.fontStyle = 1;
|
style.defined.fontStyle = 1;
|
||||||
@ -361,11 +364,11 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
|||||||
style.fontWeight = interpretFontWeight(propValue);
|
style.fontWeight = interpretFontWeight(propValue);
|
||||||
style.defined.fontWeight = 1;
|
style.defined.fontWeight = 1;
|
||||||
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
|
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
|
||||||
style.textDecoration = interpretDecoration(propValue);
|
style.decoration = interpretDecoration(propValue);
|
||||||
style.defined.textDecoration = 1;
|
style.defined.decoration = 1;
|
||||||
} else if (propName == "text-indent") {
|
} else if (propName == "text-indent") {
|
||||||
style.textIndent = interpretLength(propValue);
|
style.indent = interpretLength(propValue);
|
||||||
style.defined.textIndent = 1;
|
style.defined.indent = 1;
|
||||||
} else if (propName == "margin-top") {
|
} else if (propName == "margin-top") {
|
||||||
style.marginTop = interpretLength(propValue);
|
style.marginTop = interpretLength(propValue);
|
||||||
style.defined.marginTop = 1;
|
style.defined.marginTop = 1;
|
||||||
@ -382,10 +385,14 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
|||||||
// Shorthand: 1-4 values for top, right, bottom, left
|
// Shorthand: 1-4 values for top, right, bottom, left
|
||||||
const auto values = splitWhitespace(propValue);
|
const auto values = splitWhitespace(propValue);
|
||||||
if (!values.empty()) {
|
if (!values.empty()) {
|
||||||
style.marginTop = interpretLength(values[0]);
|
const CssLength top = interpretLength(values[0]);
|
||||||
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
|
const CssLength right = values.size() >= 2 ? interpretLength(values[1]) : top;
|
||||||
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
|
const CssLength bottom = values.size() >= 3 ? interpretLength(values[2]) : top;
|
||||||
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
|
const CssLength left = values.size() >= 4 ? interpretLength(values[3]) : right;
|
||||||
|
style.marginTop = top;
|
||||||
|
style.marginRight = right;
|
||||||
|
style.marginBottom = bottom;
|
||||||
|
style.marginLeft = left;
|
||||||
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
||||||
}
|
}
|
||||||
} else if (propName == "padding-top") {
|
} else if (propName == "padding-top") {
|
||||||
@ -404,10 +411,14 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
|||||||
// Shorthand: 1-4 values for top, right, bottom, left
|
// Shorthand: 1-4 values for top, right, bottom, left
|
||||||
const auto values = splitWhitespace(propValue);
|
const auto values = splitWhitespace(propValue);
|
||||||
if (!values.empty()) {
|
if (!values.empty()) {
|
||||||
style.paddingTop = interpretLength(values[0]);
|
const CssLength top = interpretLength(values[0]);
|
||||||
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
|
const CssLength right = values.size() >= 2 ? interpretLength(values[1]) : top;
|
||||||
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
|
const CssLength bottom = values.size() >= 3 ? interpretLength(values[2]) : top;
|
||||||
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
|
const CssLength left = values.size() >= 4 ? interpretLength(values[3]) : right;
|
||||||
|
style.paddingTop = top;
|
||||||
|
style.paddingRight = right;
|
||||||
|
style.paddingBottom = bottom;
|
||||||
|
style.paddingLeft = left;
|
||||||
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
|
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
|
||||||
style.defined.paddingLeft = 1;
|
style.defined.paddingLeft = 1;
|
||||||
}
|
}
|
||||||
@ -514,184 +525,3 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
|||||||
// Inline style parsing (static - doesn't need rule database)
|
// Inline style parsing (static - doesn't need rule database)
|
||||||
|
|
||||||
CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return parseDeclarations(styleValue); }
|
CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return parseDeclarations(styleValue); }
|
||||||
|
|
||||||
// Cache serialization
|
|
||||||
|
|
||||||
// Cache format version - increment when format changes
|
|
||||||
constexpr uint8_t CSS_CACHE_VERSION = 1;
|
|
||||||
|
|
||||||
bool CssParser::saveToCache(FsFile& file) const {
|
|
||||||
if (!file) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write version
|
|
||||||
file.write(CSS_CACHE_VERSION);
|
|
||||||
|
|
||||||
// Write rule count
|
|
||||||
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
|
|
||||||
file.write(reinterpret_cast<const uint8_t*>(&ruleCount), sizeof(ruleCount));
|
|
||||||
|
|
||||||
// Write each rule: selector string + CssStyle fields
|
|
||||||
for (const auto& pair : rulesBySelector_) {
|
|
||||||
// Write selector string (length-prefixed)
|
|
||||||
const auto selectorLen = static_cast<uint16_t>(pair.first.size());
|
|
||||||
file.write(reinterpret_cast<const uint8_t*>(&selectorLen), sizeof(selectorLen));
|
|
||||||
file.write(reinterpret_cast<const uint8_t*>(pair.first.data()), selectorLen);
|
|
||||||
|
|
||||||
// Write CssStyle fields (all are POD types)
|
|
||||||
const CssStyle& style = pair.second;
|
|
||||||
file.write(static_cast<uint8_t>(style.textAlign));
|
|
||||||
file.write(static_cast<uint8_t>(style.fontStyle));
|
|
||||||
file.write(static_cast<uint8_t>(style.fontWeight));
|
|
||||||
file.write(static_cast<uint8_t>(style.textDecoration));
|
|
||||||
|
|
||||||
// Write CssLength fields (value + unit)
|
|
||||||
auto writeLength = [&file](const CssLength& len) {
|
|
||||||
file.write(reinterpret_cast<const uint8_t*>(&len.value), sizeof(len.value));
|
|
||||||
file.write(static_cast<uint8_t>(len.unit));
|
|
||||||
};
|
|
||||||
|
|
||||||
writeLength(style.textIndent);
|
|
||||||
writeLength(style.marginTop);
|
|
||||||
writeLength(style.marginBottom);
|
|
||||||
writeLength(style.marginLeft);
|
|
||||||
writeLength(style.marginRight);
|
|
||||||
writeLength(style.paddingTop);
|
|
||||||
writeLength(style.paddingBottom);
|
|
||||||
writeLength(style.paddingLeft);
|
|
||||||
writeLength(style.paddingRight);
|
|
||||||
|
|
||||||
// Write defined flags as uint16_t
|
|
||||||
uint16_t definedBits = 0;
|
|
||||||
if (style.defined.textAlign) definedBits |= 1 << 0;
|
|
||||||
if (style.defined.fontStyle) definedBits |= 1 << 1;
|
|
||||||
if (style.defined.fontWeight) definedBits |= 1 << 2;
|
|
||||||
if (style.defined.textDecoration) definedBits |= 1 << 3;
|
|
||||||
if (style.defined.textIndent) definedBits |= 1 << 4;
|
|
||||||
if (style.defined.marginTop) definedBits |= 1 << 5;
|
|
||||||
if (style.defined.marginBottom) definedBits |= 1 << 6;
|
|
||||||
if (style.defined.marginLeft) definedBits |= 1 << 7;
|
|
||||||
if (style.defined.marginRight) definedBits |= 1 << 8;
|
|
||||||
if (style.defined.paddingTop) definedBits |= 1 << 9;
|
|
||||||
if (style.defined.paddingBottom) definedBits |= 1 << 10;
|
|
||||||
if (style.defined.paddingLeft) definedBits |= 1 << 11;
|
|
||||||
if (style.defined.paddingRight) definedBits |= 1 << 12;
|
|
||||||
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [CSS] Saved %u rules to cache\n", millis(), ruleCount);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CssParser::loadFromCache(FsFile& file) {
|
|
||||||
if (!file) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing rules
|
|
||||||
clear();
|
|
||||||
|
|
||||||
// Read and verify version
|
|
||||||
uint8_t version = 0;
|
|
||||||
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
|
|
||||||
Serial.printf("[%lu] [CSS] Cache version mismatch (got %u, expected %u)\n", millis(), version, CSS_CACHE_VERSION);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read rule count
|
|
||||||
uint16_t ruleCount = 0;
|
|
||||||
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read each rule
|
|
||||||
for (uint16_t i = 0; i < ruleCount; ++i) {
|
|
||||||
// Read selector string
|
|
||||||
uint16_t selectorLen = 0;
|
|
||||||
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string selector;
|
|
||||||
selector.resize(selectorLen);
|
|
||||||
if (file.read(&selector[0], selectorLen) != selectorLen) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read CssStyle fields
|
|
||||||
CssStyle style;
|
|
||||||
uint8_t enumVal;
|
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
style.textAlign = static_cast<CssTextAlign>(enumVal);
|
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
style.fontStyle = static_cast<CssFontStyle>(enumVal);
|
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
style.fontWeight = static_cast<CssFontWeight>(enumVal);
|
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
|
||||||
|
|
||||||
// Read CssLength fields
|
|
||||||
auto readLength = [&file](CssLength& len) -> bool {
|
|
||||||
if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
uint8_t unitVal;
|
|
||||||
if (file.read(&unitVal, 1) != 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
len.unit = static_cast<CssUnit>(unitVal);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
|
|
||||||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
|
||||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read defined flags
|
|
||||||
uint16_t definedBits = 0;
|
|
||||||
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
|
|
||||||
rulesBySelector_.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
style.defined.textAlign = (definedBits & 1 << 0) != 0;
|
|
||||||
style.defined.fontStyle = (definedBits & 1 << 1) != 0;
|
|
||||||
style.defined.fontWeight = (definedBits & 1 << 2) != 0;
|
|
||||||
style.defined.textDecoration = (definedBits & 1 << 3) != 0;
|
|
||||||
style.defined.textIndent = (definedBits & 1 << 4) != 0;
|
|
||||||
style.defined.marginTop = (definedBits & 1 << 5) != 0;
|
|
||||||
style.defined.marginBottom = (definedBits & 1 << 6) != 0;
|
|
||||||
style.defined.marginLeft = (definedBits & 1 << 7) != 0;
|
|
||||||
style.defined.marginRight = (definedBits & 1 << 8) != 0;
|
|
||||||
style.defined.paddingTop = (definedBits & 1 << 9) != 0;
|
|
||||||
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
|
|
||||||
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
|
|
||||||
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
|
|
||||||
|
|
||||||
rulesBySelector_[selector] = style;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [CSS] Loaded %u rules from cache\n", millis(), ruleCount);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -76,21 +76,6 @@ class CssParser {
|
|||||||
*/
|
*/
|
||||||
void clear() { rulesBySelector_.clear(); }
|
void clear() { rulesBySelector_.clear(); }
|
||||||
|
|
||||||
/**
|
|
||||||
* Save parsed CSS rules to a cache file.
|
|
||||||
* @param file Open file handle to write to
|
|
||||||
* @return true if cache was written successfully
|
|
||||||
*/
|
|
||||||
bool saveToCache(FsFile& file) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load CSS rules from a cache file.
|
|
||||||
* Clears any existing rules before loading.
|
|
||||||
* @param file Open file handle to read from
|
|
||||||
* @return true if cache was loaded successfully
|
|
||||||
*/
|
|
||||||
bool loadFromCache(FsFile& file);
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Storage: maps normalized selector -> style properties
|
// Storage: maps normalized selector -> style properties
|
||||||
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
||||||
@ -100,7 +85,7 @@ class CssParser {
|
|||||||
static CssStyle parseDeclarations(const std::string& declBlock);
|
static CssStyle parseDeclarations(const std::string& declBlock);
|
||||||
|
|
||||||
// Individual property value parsers
|
// Individual property value parsers
|
||||||
static CssTextAlign interpretAlignment(const std::string& val);
|
static TextAlign interpretAlignment(const std::string& val);
|
||||||
static CssFontStyle interpretFontStyle(const std::string& val);
|
static CssFontStyle interpretFontStyle(const std::string& val);
|
||||||
static CssFontWeight interpretFontWeight(const std::string& val);
|
static CssFontWeight interpretFontWeight(const std::string& val);
|
||||||
static CssTextDecoration interpretDecoration(const std::string& val);
|
static CssTextDecoration interpretDecoration(const std::string& val);
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
// Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings
|
// Text alignment options matching CSS text-align property
|
||||||
enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3 };
|
enum class TextAlign : uint8_t { None = 0, Left = 1, Right = 2, Center = 3, Justify = 4 };
|
||||||
|
|
||||||
|
// CSS length unit types
|
||||||
enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3 };
|
enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3 };
|
||||||
|
|
||||||
// Represents a CSS length value with its unit, allowing deferred resolution to pixels
|
// Represents a CSS length value with its unit, allowing deferred resolution to pixels
|
||||||
@ -45,11 +47,11 @@ enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
|
|||||||
|
|
||||||
// Bitmask for tracking which properties have been explicitly set
|
// Bitmask for tracking which properties have been explicitly set
|
||||||
struct CssPropertyFlags {
|
struct CssPropertyFlags {
|
||||||
uint16_t textAlign : 1;
|
uint16_t alignment : 1;
|
||||||
uint16_t fontStyle : 1;
|
uint16_t fontStyle : 1;
|
||||||
uint16_t fontWeight : 1;
|
uint16_t fontWeight : 1;
|
||||||
uint16_t textDecoration : 1;
|
uint16_t decoration : 1;
|
||||||
uint16_t textIndent : 1;
|
uint16_t indent : 1;
|
||||||
uint16_t marginTop : 1;
|
uint16_t marginTop : 1;
|
||||||
uint16_t marginBottom : 1;
|
uint16_t marginBottom : 1;
|
||||||
uint16_t marginLeft : 1;
|
uint16_t marginLeft : 1;
|
||||||
@ -58,13 +60,14 @@ struct CssPropertyFlags {
|
|||||||
uint16_t paddingBottom : 1;
|
uint16_t paddingBottom : 1;
|
||||||
uint16_t paddingLeft : 1;
|
uint16_t paddingLeft : 1;
|
||||||
uint16_t paddingRight : 1;
|
uint16_t paddingRight : 1;
|
||||||
|
uint16_t reserved : 3;
|
||||||
|
|
||||||
CssPropertyFlags()
|
CssPropertyFlags()
|
||||||
: textAlign(0),
|
: alignment(0),
|
||||||
fontStyle(0),
|
fontStyle(0),
|
||||||
fontWeight(0),
|
fontWeight(0),
|
||||||
textDecoration(0),
|
decoration(0),
|
||||||
textIndent(0),
|
indent(0),
|
||||||
marginTop(0),
|
marginTop(0),
|
||||||
marginBottom(0),
|
marginBottom(0),
|
||||||
marginLeft(0),
|
marginLeft(0),
|
||||||
@ -72,15 +75,16 @@ struct CssPropertyFlags {
|
|||||||
paddingTop(0),
|
paddingTop(0),
|
||||||
paddingBottom(0),
|
paddingBottom(0),
|
||||||
paddingLeft(0),
|
paddingLeft(0),
|
||||||
paddingRight(0) {}
|
paddingRight(0),
|
||||||
|
reserved(0) {}
|
||||||
|
|
||||||
[[nodiscard]] bool anySet() const {
|
[[nodiscard]] bool anySet() const {
|
||||||
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
|
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || marginLeft ||
|
||||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
|
marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
|
alignment = fontStyle = fontWeight = decoration = indent = 0;
|
||||||
marginTop = marginBottom = marginLeft = marginRight = 0;
|
marginTop = marginBottom = marginLeft = marginRight = 0;
|
||||||
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
||||||
}
|
}
|
||||||
@ -90,12 +94,12 @@ struct CssPropertyFlags {
|
|||||||
// Only stores properties relevant to e-ink text rendering
|
// Only stores properties relevant to e-ink text rendering
|
||||||
// Length values are stored as CssLength (value + unit) for deferred resolution
|
// Length values are stored as CssLength (value + unit) for deferred resolution
|
||||||
struct CssStyle {
|
struct CssStyle {
|
||||||
CssTextAlign textAlign = CssTextAlign::Left;
|
TextAlign alignment = TextAlign::None;
|
||||||
CssFontStyle fontStyle = CssFontStyle::Normal;
|
CssFontStyle fontStyle = CssFontStyle::Normal;
|
||||||
CssFontWeight fontWeight = CssFontWeight::Normal;
|
CssFontWeight fontWeight = CssFontWeight::Normal;
|
||||||
CssTextDecoration textDecoration = CssTextDecoration::None;
|
CssTextDecoration decoration = CssTextDecoration::None;
|
||||||
|
|
||||||
CssLength textIndent; // First-line indent (deferred resolution)
|
CssLength indent; // First-line indent (deferred resolution)
|
||||||
CssLength marginTop; // Vertical spacing before block
|
CssLength marginTop; // Vertical spacing before block
|
||||||
CssLength marginBottom; // Vertical spacing after block
|
CssLength marginBottom; // Vertical spacing after block
|
||||||
CssLength marginLeft; // Horizontal spacing left of block
|
CssLength marginLeft; // Horizontal spacing left of block
|
||||||
@ -110,65 +114,66 @@ struct CssStyle {
|
|||||||
// Apply properties from another style, only overwriting if the other style
|
// Apply properties from another style, only overwriting if the other style
|
||||||
// has that property explicitly defined
|
// has that property explicitly defined
|
||||||
void applyOver(const CssStyle& base) {
|
void applyOver(const CssStyle& base) {
|
||||||
if (base.hasTextAlign()) {
|
if (base.defined.alignment) {
|
||||||
textAlign = base.textAlign;
|
alignment = base.alignment;
|
||||||
defined.textAlign = 1;
|
defined.alignment = 1;
|
||||||
}
|
}
|
||||||
if (base.hasFontStyle()) {
|
if (base.defined.fontStyle) {
|
||||||
fontStyle = base.fontStyle;
|
fontStyle = base.fontStyle;
|
||||||
defined.fontStyle = 1;
|
defined.fontStyle = 1;
|
||||||
}
|
}
|
||||||
if (base.hasFontWeight()) {
|
if (base.defined.fontWeight) {
|
||||||
fontWeight = base.fontWeight;
|
fontWeight = base.fontWeight;
|
||||||
defined.fontWeight = 1;
|
defined.fontWeight = 1;
|
||||||
}
|
}
|
||||||
if (base.hasTextDecoration()) {
|
if (base.defined.decoration) {
|
||||||
textDecoration = base.textDecoration;
|
decoration = base.decoration;
|
||||||
defined.textDecoration = 1;
|
defined.decoration = 1;
|
||||||
}
|
}
|
||||||
if (base.hasTextIndent()) {
|
if (base.defined.indent) {
|
||||||
textIndent = base.textIndent;
|
indent = base.indent;
|
||||||
defined.textIndent = 1;
|
defined.indent = 1;
|
||||||
}
|
}
|
||||||
if (base.hasMarginTop()) {
|
if (base.defined.marginTop) {
|
||||||
marginTop = base.marginTop;
|
marginTop = base.marginTop;
|
||||||
defined.marginTop = 1;
|
defined.marginTop = 1;
|
||||||
}
|
}
|
||||||
if (base.hasMarginBottom()) {
|
if (base.defined.marginBottom) {
|
||||||
marginBottom = base.marginBottom;
|
marginBottom = base.marginBottom;
|
||||||
defined.marginBottom = 1;
|
defined.marginBottom = 1;
|
||||||
}
|
}
|
||||||
if (base.hasMarginLeft()) {
|
if (base.defined.marginLeft) {
|
||||||
marginLeft = base.marginLeft;
|
marginLeft = base.marginLeft;
|
||||||
defined.marginLeft = 1;
|
defined.marginLeft = 1;
|
||||||
}
|
}
|
||||||
if (base.hasMarginRight()) {
|
if (base.defined.marginRight) {
|
||||||
marginRight = base.marginRight;
|
marginRight = base.marginRight;
|
||||||
defined.marginRight = 1;
|
defined.marginRight = 1;
|
||||||
}
|
}
|
||||||
if (base.hasPaddingTop()) {
|
if (base.defined.paddingTop) {
|
||||||
paddingTop = base.paddingTop;
|
paddingTop = base.paddingTop;
|
||||||
defined.paddingTop = 1;
|
defined.paddingTop = 1;
|
||||||
}
|
}
|
||||||
if (base.hasPaddingBottom()) {
|
if (base.defined.paddingBottom) {
|
||||||
paddingBottom = base.paddingBottom;
|
paddingBottom = base.paddingBottom;
|
||||||
defined.paddingBottom = 1;
|
defined.paddingBottom = 1;
|
||||||
}
|
}
|
||||||
if (base.hasPaddingLeft()) {
|
if (base.defined.paddingLeft) {
|
||||||
paddingLeft = base.paddingLeft;
|
paddingLeft = base.paddingLeft;
|
||||||
defined.paddingLeft = 1;
|
defined.paddingLeft = 1;
|
||||||
}
|
}
|
||||||
if (base.hasPaddingRight()) {
|
if (base.defined.paddingRight) {
|
||||||
paddingRight = base.paddingRight;
|
paddingRight = base.paddingRight;
|
||||||
defined.paddingRight = 1;
|
defined.paddingRight = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
|
// Compatibility accessors for existing code that uses hasX pattern
|
||||||
|
[[nodiscard]] bool hasTextAlign() const { return defined.alignment; }
|
||||||
[[nodiscard]] bool hasFontStyle() const { return defined.fontStyle; }
|
[[nodiscard]] bool hasFontStyle() const { return defined.fontStyle; }
|
||||||
[[nodiscard]] bool hasFontWeight() const { return defined.fontWeight; }
|
[[nodiscard]] bool hasFontWeight() const { return defined.fontWeight; }
|
||||||
[[nodiscard]] bool hasTextDecoration() const { return defined.textDecoration; }
|
[[nodiscard]] bool hasTextDecoration() const { return defined.decoration; }
|
||||||
[[nodiscard]] bool hasTextIndent() const { return defined.textIndent; }
|
[[nodiscard]] bool hasTextIndent() const { return defined.indent; }
|
||||||
[[nodiscard]] bool hasMarginTop() const { return defined.marginTop; }
|
[[nodiscard]] bool hasMarginTop() const { return defined.marginTop; }
|
||||||
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
|
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
|
||||||
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
|
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
|
||||||
@ -178,12 +183,15 @@ struct CssStyle {
|
|||||||
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
||||||
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
||||||
|
|
||||||
|
// Merge another style (alias for applyOver for compatibility)
|
||||||
|
void merge(const CssStyle& other) { applyOver(other); }
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
textAlign = CssTextAlign::Left;
|
alignment = TextAlign::None;
|
||||||
fontStyle = CssFontStyle::Normal;
|
fontStyle = CssFontStyle::Normal;
|
||||||
fontWeight = CssFontWeight::Normal;
|
fontWeight = CssFontWeight::Normal;
|
||||||
textDecoration = CssTextDecoration::None;
|
decoration = CssTextDecoration::None;
|
||||||
textIndent = CssLength{};
|
indent = CssLength{};
|
||||||
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
||||||
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
||||||
defined.clearAll();
|
defined.clearAll();
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
||||||
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
|
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
|
||||||
|
|
||||||
// Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
|
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it
|
||||||
constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB
|
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
||||||
|
|
||||||
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
||||||
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
|
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
|
||||||
@ -43,17 +43,39 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isHeaderOrBlock(const char* name) {
|
// Create a BlockStyle from CSS style properties, resolving CssLength values to pixels
|
||||||
return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
// emSize is the current font line height, used for em/rem unit conversion
|
||||||
|
BlockStyle createBlockStyleFromCss(const CssStyle& cssStyle, const float emSize) {
|
||||||
|
BlockStyle blockStyle;
|
||||||
|
// Resolve all CssLength values to pixels using the current font's em size
|
||||||
|
const int16_t marginTopPx = cssStyle.marginTop.toPixelsInt16(emSize);
|
||||||
|
const int16_t marginBottomPx = cssStyle.marginBottom.toPixelsInt16(emSize);
|
||||||
|
const int16_t paddingTopPx = cssStyle.paddingTop.toPixelsInt16(emSize);
|
||||||
|
const int16_t paddingBottomPx = cssStyle.paddingBottom.toPixelsInt16(emSize);
|
||||||
|
|
||||||
|
// Vertical: combine margin and padding for top/bottom spacing
|
||||||
|
blockStyle.marginTop = static_cast<int16_t>(marginTopPx + paddingTopPx);
|
||||||
|
blockStyle.marginBottom = static_cast<int16_t>(marginBottomPx + paddingBottomPx);
|
||||||
|
blockStyle.paddingTop = paddingTopPx;
|
||||||
|
blockStyle.paddingBottom = paddingBottomPx;
|
||||||
|
// Horizontal: store margin and padding separately for layout calculations
|
||||||
|
blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize);
|
||||||
|
blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize);
|
||||||
|
blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize);
|
||||||
|
blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize);
|
||||||
|
// Text indent
|
||||||
|
blockStyle.textIndent = cssStyle.indent.toPixelsInt16(emSize);
|
||||||
|
blockStyle.textIndentDefined = cssStyle.defined.indent;
|
||||||
|
return blockStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update effective bold/italic/underline based on block style and inline style stack
|
// Update effective bold/italic/underline based on block style and inline style stack
|
||||||
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||||
// Start with block-level styles
|
// Start with block-level styles
|
||||||
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
effectiveBold = currentBlockStyle.hasFontWeight() && currentBlockStyle.fontWeight == CssFontWeight::Bold;
|
||||||
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
effectiveItalic = currentBlockStyle.hasFontStyle() && currentBlockStyle.fontStyle == CssFontStyle::Italic;
|
||||||
effectiveUnderline =
|
effectiveUnderline =
|
||||||
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
|
currentBlockStyle.hasTextDecoration() && currentBlockStyle.decoration == CssTextDecoration::Underline;
|
||||||
|
|
||||||
// Apply inline style stack in order
|
// Apply inline style stack in order
|
||||||
for (const auto& entry : inlineStyleStack) {
|
for (const auto& entry : inlineStyleStack) {
|
||||||
@ -76,41 +98,69 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
|||||||
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
||||||
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
|
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
|
||||||
|
|
||||||
// Combine style flags using bitwise OR
|
|
||||||
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
||||||
if (isBold) {
|
if (isBold && isItalic) {
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::BOLD);
|
fontStyle = EpdFontFamily::BOLD_ITALIC;
|
||||||
}
|
} else if (isBold) {
|
||||||
if (isItalic) {
|
fontStyle = EpdFontFamily::BOLD;
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
} else if (isItalic) {
|
||||||
}
|
fontStyle = EpdFontFamily::ITALIC;
|
||||||
if (isUnderline) {
|
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// flush the buffer
|
// flush the buffer
|
||||||
partWordBuffer[partWordBufferIndex] = '\0';
|
partWordBuffer[partWordBufferIndex] = '\0';
|
||||||
currentTextBlock->addWord(partWordBuffer, fontStyle);
|
currentTextBlock->addWord(partWordBuffer, fontStyle, isUnderline);
|
||||||
partWordBufferIndex = 0;
|
partWordBufferIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge block styles for nested block elements
|
||||||
|
// When a child block element is inside a parent with no direct text content,
|
||||||
|
// we accumulate their margins so nested containers properly contribute spacing
|
||||||
|
BlockStyle mergeBlockStyles(const BlockStyle& parent, const BlockStyle& child) {
|
||||||
|
BlockStyle merged;
|
||||||
|
// Vertical margins: sum them (nested blocks create additive spacing)
|
||||||
|
merged.marginTop = static_cast<int16_t>(parent.marginTop + child.marginTop);
|
||||||
|
merged.marginBottom = static_cast<int16_t>(parent.marginBottom + child.marginBottom);
|
||||||
|
// Horizontal margins: sum them (nested blocks create cumulative indentation)
|
||||||
|
merged.marginLeft = static_cast<int16_t>(parent.marginLeft + child.marginLeft);
|
||||||
|
merged.marginRight = static_cast<int16_t>(parent.marginRight + child.marginRight);
|
||||||
|
// Padding: sum them
|
||||||
|
merged.paddingTop = static_cast<int16_t>(parent.paddingTop + child.paddingTop);
|
||||||
|
merged.paddingBottom = static_cast<int16_t>(parent.paddingBottom + child.paddingBottom);
|
||||||
|
merged.paddingLeft = static_cast<int16_t>(parent.paddingLeft + child.paddingLeft);
|
||||||
|
merged.paddingRight = static_cast<int16_t>(parent.paddingRight + child.paddingRight);
|
||||||
|
// Text indent: use child's if defined, otherwise inherit parent's
|
||||||
|
if (child.textIndentDefined) {
|
||||||
|
merged.textIndent = child.textIndent;
|
||||||
|
merged.textIndentDefined = true;
|
||||||
|
} else if (parent.textIndentDefined) {
|
||||||
|
merged.textIndent = parent.textIndent;
|
||||||
|
merged.textIndentDefined = true;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
// start a new text block if needed
|
// start a new text block if needed
|
||||||
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style, const BlockStyle& blockStyle) {
|
||||||
if (currentTextBlock) {
|
if (currentTextBlock) {
|
||||||
// already have a text block running and it is empty - just reuse it
|
// already have a text block running and it is empty - just reuse it
|
||||||
if (currentTextBlock->isEmpty()) {
|
if (currentTextBlock->isEmpty()) {
|
||||||
// Merge with existing block style to accumulate CSS styling from parent block elements.
|
currentTextBlock->setStyle(style);
|
||||||
// This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the
|
// Merge with existing block style to accumulate margins from parent block elements
|
||||||
// div's margin should be preserved, even though it has no direct text content.
|
// This handles cases like <div margin-bottom:2em><h1>text</h1></div> where the
|
||||||
currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle));
|
// div's margin should be preserved even though it has no direct text content
|
||||||
|
const BlockStyle merged = mergeBlockStyles(currentTextBlock->getBlockStyle(), blockStyle);
|
||||||
|
currentTextBlock->setBlockStyle(merged);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
makePages();
|
makePages();
|
||||||
}
|
}
|
||||||
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
|
currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing, hyphenationEnabled, blockStyle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) { startNewTextBlock(style, BlockStyle{}); }
|
||||||
|
|
||||||
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
||||||
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
|
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
|
||||||
|
|
||||||
@ -133,17 +183,13 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto centeredBlockStyle = BlockStyle();
|
|
||||||
centeredBlockStyle.textAlignDefined = true;
|
|
||||||
centeredBlockStyle.alignment = CssTextAlign::Center;
|
|
||||||
|
|
||||||
// Special handling for tables - show placeholder text instead of dropping silently
|
// Special handling for tables - show placeholder text instead of dropping silently
|
||||||
if (strcmp(name, "table") == 0) {
|
if (strcmp(name, "table") == 0) {
|
||||||
// Add placeholder text
|
// Add placeholder text
|
||||||
self->startNewTextBlock(centeredBlockStyle);
|
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
|
||||||
|
|
||||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
||||||
// Advance depth before processing character data (like you would for an element with text)
|
// Advance depth before processing character data (like you would for a element with text)
|
||||||
self->depth += 1;
|
self->depth += 1;
|
||||||
self->characterData(userData, "[Table omitted]", strlen("[Table omitted]"));
|
self->characterData(userData, "[Table omitted]", strlen("[Table omitted]"));
|
||||||
|
|
||||||
@ -168,9 +214,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
|
|
||||||
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
|
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
|
||||||
|
|
||||||
self->startNewTextBlock(centeredBlockStyle);
|
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
|
||||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
||||||
// Advance depth before processing character data (like you would for an element with text)
|
// Advance depth before processing character data (like you would for a element with text)
|
||||||
self->depth += 1;
|
self->depth += 1;
|
||||||
self->characterData(userData, alt.c_str(), alt.length());
|
self->characterData(userData, alt.c_str(), alt.length());
|
||||||
|
|
||||||
@ -198,6 +244,9 @@ 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);
|
||||||
|
|
||||||
// Compute CSS style for this element
|
// Compute CSS style for this element
|
||||||
CssStyle cssStyle;
|
CssStyle cssStyle;
|
||||||
if (self->cssParser) {
|
if (self->cssParser) {
|
||||||
@ -206,16 +255,34 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
// Merge inline style (highest priority)
|
// Merge inline style (highest priority)
|
||||||
if (!styleAttr.empty()) {
|
if (!styleAttr.empty()) {
|
||||||
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
|
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
|
||||||
cssStyle.applyOver(inlineStyle);
|
cssStyle.merge(inlineStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const float emSize = static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
|
|
||||||
const auto userAlignment = static_cast<CssTextAlign>(self->paragraphAlignment);
|
|
||||||
|
|
||||||
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
|
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
|
||||||
self->currentCssStyle = cssStyle;
|
// Headers: center aligned, bold, apply CSS overrides
|
||||||
self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment));
|
TextBlock::Style alignment = TextBlock::CENTER_ALIGN;
|
||||||
|
if (cssStyle.hasTextAlign()) {
|
||||||
|
switch (cssStyle.alignment) {
|
||||||
|
case TextAlign::Left:
|
||||||
|
alignment = TextBlock::LEFT_ALIGN;
|
||||||
|
break;
|
||||||
|
case TextAlign::Right:
|
||||||
|
alignment = TextBlock::RIGHT_ALIGN;
|
||||||
|
break;
|
||||||
|
case TextAlign::Center:
|
||||||
|
alignment = TextBlock::CENTER_ALIGN;
|
||||||
|
break;
|
||||||
|
case TextAlign::Justify:
|
||||||
|
alignment = TextBlock::JUSTIFIED;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self->currentBlockStyle = cssStyle;
|
||||||
|
self->startNewTextBlock(alignment, createBlockStyleFromCss(cssStyle, self->renderer.getLineHeight(self->fontId)));
|
||||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
|
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
|
||||||
@ -224,10 +291,31 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
||||||
self->flushPartWordBuffer();
|
self->flushPartWordBuffer();
|
||||||
}
|
}
|
||||||
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
|
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
||||||
} else {
|
} else {
|
||||||
self->currentCssStyle = cssStyle;
|
// Determine alignment from CSS or default
|
||||||
self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment));
|
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
|
||||||
|
if (cssStyle.hasTextAlign()) {
|
||||||
|
switch (cssStyle.alignment) {
|
||||||
|
case TextAlign::Left:
|
||||||
|
alignment = TextBlock::LEFT_ALIGN;
|
||||||
|
break;
|
||||||
|
case TextAlign::Right:
|
||||||
|
alignment = TextBlock::RIGHT_ALIGN;
|
||||||
|
break;
|
||||||
|
case TextAlign::Center:
|
||||||
|
alignment = TextBlock::CENTER_ALIGN;
|
||||||
|
break;
|
||||||
|
case TextAlign::Justify:
|
||||||
|
alignment = TextBlock::JUSTIFIED;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self->currentBlockStyle = cssStyle;
|
||||||
|
self->startNewTextBlock(alignment, createBlockStyleFromCss(cssStyle, self->renderer.getLineHeight(self->fontId)));
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
|
|
||||||
if (strcmp(name, "li") == 0) {
|
if (strcmp(name, "li") == 0) {
|
||||||
@ -264,7 +352,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
if (cssStyle.hasTextDecoration()) {
|
if (cssStyle.hasTextDecoration()) {
|
||||||
entry.hasUnderline = true;
|
entry.hasUnderline = true;
|
||||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
entry.underline = cssStyle.decoration == CssTextDecoration::Underline;
|
||||||
}
|
}
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@ -281,11 +369,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
if (cssStyle.hasTextDecoration()) {
|
if (cssStyle.hasTextDecoration()) {
|
||||||
entry.hasUnderline = true;
|
entry.hasUnderline = true;
|
||||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
entry.underline = cssStyle.decoration == CssTextDecoration::Underline;
|
||||||
}
|
}
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) {
|
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
|
||||||
// Handle span and other inline elements for CSS styling
|
// Handle span and other inline elements for CSS styling
|
||||||
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@ -300,7 +388,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
if (cssStyle.hasTextDecoration()) {
|
if (cssStyle.hasTextDecoration()) {
|
||||||
entry.hasUnderline = true;
|
entry.hasUnderline = true;
|
||||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
entry.underline = cssStyle.decoration == CssTextDecoration::Underline;
|
||||||
}
|
}
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@ -376,12 +464,12 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
|
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
|
||||||
|
|
||||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
||||||
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
|
||||||
|
|
||||||
// Flush buffer with current style BEFORE any style changes
|
// Flush buffer with current style BEFORE any style changes
|
||||||
if (self->partWordBufferIndex > 0) {
|
if (self->partWordBufferIndex > 0) {
|
||||||
// Flush if style will change OR if we're closing a block/structural element
|
// Flush if style will change OR if we're closing a block/structural element
|
||||||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
|
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, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
|
||||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 ||
|
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 ||
|
||||||
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
|
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
|
||||||
@ -420,18 +508,15 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear block style when leaving header or block elements
|
// Clear block style when leaving block elements
|
||||||
if (headerOrBlockTag) {
|
if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
|
||||||
self->currentCssStyle.reset();
|
self->currentBlockStyle.reset();
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||||
auto paragraphAlignmentBlockStyle = BlockStyle();
|
startNewTextBlock((TextBlock::Style)this->paragraphAlignment);
|
||||||
paragraphAlignmentBlockStyle.textAlignDefined = true;
|
|
||||||
paragraphAlignmentBlockStyle.alignment = static_cast<CssTextAlign>(this->paragraphAlignment);
|
|
||||||
startNewTextBlock(paragraphAlignmentBlockStyle);
|
|
||||||
|
|
||||||
const XML_Parser parser = XML_ParserCreate(nullptr);
|
const XML_Parser parser = XML_ParserCreate(nullptr);
|
||||||
int done;
|
int done;
|
||||||
@ -447,10 +532,10 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file size to decide whether to show indexing popup.
|
// Get file size for progress calculation
|
||||||
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
|
const size_t totalSize = file.size();
|
||||||
popupFn();
|
size_t bytesRead = 0;
|
||||||
}
|
int lastProgress = -1;
|
||||||
|
|
||||||
XML_SetUserData(parser, this);
|
XML_SetUserData(parser, this);
|
||||||
XML_SetElementHandler(parser, startElement, endElement);
|
XML_SetElementHandler(parser, startElement, endElement);
|
||||||
@ -480,6 +565,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update progress (call every 10% change to avoid too frequent updates)
|
||||||
|
// Only show progress for larger chapters where rendering overhead is worth it
|
||||||
|
bytesRead += len;
|
||||||
|
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
|
||||||
|
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
|
||||||
|
if (lastProgress / 10 != progress / 10) {
|
||||||
|
lastProgress = progress;
|
||||||
|
progressFn(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
done = file.available() == 0;
|
done = file.available() == 0;
|
||||||
|
|
||||||
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||||
@ -539,14 +635,11 @@ void ChapterHtmlSlimParser::makePages() {
|
|||||||
|
|
||||||
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
||||||
|
|
||||||
// Apply top spacing before the paragraph (stored in pixels)
|
// Apply marginTop before the paragraph (stored in pixels)
|
||||||
const BlockStyle& blockStyle = currentTextBlock->getBlockStyle();
|
const BlockStyle& blockStyle = currentTextBlock->getBlockStyle();
|
||||||
if (blockStyle.marginTop > 0) {
|
if (blockStyle.marginTop > 0) {
|
||||||
currentPageNextY += blockStyle.marginTop;
|
currentPageNextY += blockStyle.marginTop;
|
||||||
}
|
}
|
||||||
if (blockStyle.paddingTop > 0) {
|
|
||||||
currentPageNextY += blockStyle.paddingTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate effective width accounting for horizontal margins/padding
|
// Calculate effective width accounting for horizontal margins/padding
|
||||||
const int horizontalInset = blockStyle.totalHorizontalInset();
|
const int horizontalInset = blockStyle.totalHorizontalInset();
|
||||||
@ -557,13 +650,10 @@ void ChapterHtmlSlimParser::makePages() {
|
|||||||
renderer, fontId, effectiveWidth,
|
renderer, fontId, effectiveWidth,
|
||||||
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
|
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
|
||||||
|
|
||||||
// Apply bottom spacing after the paragraph (stored in pixels)
|
// Apply marginBottom after the paragraph (stored in pixels)
|
||||||
if (blockStyle.marginBottom > 0) {
|
if (blockStyle.marginBottom > 0) {
|
||||||
currentPageNextY += blockStyle.marginBottom;
|
currentPageNextY += blockStyle.marginBottom;
|
||||||
}
|
}
|
||||||
if (blockStyle.paddingBottom > 0) {
|
|
||||||
currentPageNextY += blockStyle.paddingBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extra paragraph spacing if enabled (default behavior)
|
// Extra paragraph spacing if enabled (default behavior)
|
||||||
if (extraParagraphSpacing) {
|
if (extraParagraphSpacing) {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class ChapterHtmlSlimParser {
|
|||||||
const std::string& filepath;
|
const std::string& filepath;
|
||||||
GfxRenderer& renderer;
|
GfxRenderer& renderer;
|
||||||
std::function<void(std::unique_ptr<Page>)> completePageFn;
|
std::function<void(std::unique_ptr<Page>)> completePageFn;
|
||||||
std::function<void()> popupFn; // Popup callback
|
std::function<void(int)> progressFn; // Progress callback (0-100)
|
||||||
int depth = 0;
|
int depth = 0;
|
||||||
int skipUntilDepth = INT_MAX;
|
int skipUntilDepth = INT_MAX;
|
||||||
int boldUntilDepth = INT_MAX;
|
int boldUntilDepth = INT_MAX;
|
||||||
@ -50,13 +50,14 @@ class ChapterHtmlSlimParser {
|
|||||||
bool hasUnderline = false, underline = false;
|
bool hasUnderline = false, underline = false;
|
||||||
};
|
};
|
||||||
std::vector<StyleStackEntry> inlineStyleStack;
|
std::vector<StyleStackEntry> inlineStyleStack;
|
||||||
CssStyle currentCssStyle;
|
CssStyle currentBlockStyle;
|
||||||
bool effectiveBold = false;
|
bool effectiveBold = false;
|
||||||
bool effectiveItalic = false;
|
bool effectiveItalic = false;
|
||||||
bool effectiveUnderline = false;
|
bool effectiveUnderline = false;
|
||||||
|
|
||||||
void updateEffectiveInlineStyle();
|
void updateEffectiveInlineStyle();
|
||||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
void startNewTextBlock(TextBlock::Style style, const BlockStyle& blockStyle);
|
||||||
|
void startNewTextBlock(TextBlock::Style style);
|
||||||
void flushPartWordBuffer();
|
void flushPartWordBuffer();
|
||||||
void makePages();
|
void makePages();
|
||||||
// XML callbacks
|
// XML callbacks
|
||||||
@ -70,8 +71,8 @@ class ChapterHtmlSlimParser {
|
|||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
||||||
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
|
const std::function<void(int)>& progressFn = nullptr,
|
||||||
|
const CssParser* cssParser = nullptr)
|
||||||
: filepath(filepath),
|
: filepath(filepath),
|
||||||
renderer(renderer),
|
renderer(renderer),
|
||||||
fontId(fontId),
|
fontId(fontId),
|
||||||
@ -82,9 +83,8 @@ class ChapterHtmlSlimParser {
|
|||||||
viewportHeight(viewportHeight),
|
viewportHeight(viewportHeight),
|
||||||
hyphenationEnabled(hyphenationEnabled),
|
hyphenationEnabled(hyphenationEnabled),
|
||||||
completePageFn(completePageFn),
|
completePageFn(completePageFn),
|
||||||
popupFn(popupFn),
|
progressFn(progressFn),
|
||||||
cssParser(cssParser) {}
|
cssParser(cssParser) {}
|
||||||
|
|
||||||
~ChapterHtmlSlimParser() = default;
|
~ChapterHtmlSlimParser() = default;
|
||||||
bool parseAndBuildPages();
|
bool parseAndBuildPages();
|
||||||
void addLineToPage(std::shared_ptr<TextBlock> line);
|
void addLineToPage(std::shared_ptr<TextBlock> line);
|
||||||
|
|||||||
@ -415,21 +415,13 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
|
|||||||
|
|
||||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||||
const EpdFontFamily::Style style) const {
|
const EpdFontFamily::Style style) const {
|
||||||
if (!text || maxWidth <= 0) return "";
|
|
||||||
|
|
||||||
std::string item = text;
|
std::string item = text;
|
||||||
const char* ellipsis = "...";
|
int itemWidth = getTextWidth(fontId, item.c_str(), style);
|
||||||
int textWidth = getTextWidth(fontId, item.c_str(), style);
|
while (itemWidth > maxWidth && item.length() > 8) {
|
||||||
if (textWidth <= maxWidth) {
|
item.replace(item.length() - 5, 5, "...");
|
||||||
// Text fits, return as is
|
itemWidth = getTextWidth(fontId, item.c_str(), style);
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
|
return item;
|
||||||
while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) {
|
|
||||||
utf8RemoveLastChar(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return item.empty() ? ellipsis : item + ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
|
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
|
||||||
@ -470,7 +462,7 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
|
|||||||
return fontMap.at(fontId).getGlyph(' ', EpdFontFamily::REGULAR)->advanceX;
|
return fontMap.at(fontId).getGlyph(' ', EpdFontFamily::REGULAR)->advanceX;
|
||||||
}
|
}
|
||||||
|
|
||||||
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const {
|
int GfxRenderer::getIndentWidth(const int fontId, const char* text) const {
|
||||||
if (fontMap.count(fontId) == 0) {
|
if (fontMap.count(fontId) == 0) {
|
||||||
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class GfxRenderer {
|
|||||||
int getScreenHeight() const;
|
int getScreenHeight() const;
|
||||||
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||||
// void displayWindow(int x, int y, int width, int height) const;
|
void displayWindow(int x, int y, int width, int height) const;
|
||||||
void invertScreen() const;
|
void invertScreen() const;
|
||||||
void clearScreen(uint8_t color = 0xFF) const;
|
void clearScreen(uint8_t color = 0xFF) const;
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ class GfxRenderer {
|
|||||||
void drawText(int fontId, int x, int y, const char* text, bool black = true,
|
void drawText(int fontId, int x, int y, const char* text, bool black = true,
|
||||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||||
int getSpaceWidth(int fontId) const;
|
int getSpaceWidth(int fontId) const;
|
||||||
int getTextAdvanceX(int fontId, const char* text) const;
|
int getIndentWidth(int fontId, const char* text) const;
|
||||||
int getFontAscenderSize(int fontId) const;
|
int getFontAscenderSize(int fontId) const;
|
||||||
int getLineHeight(int fontId) const;
|
int getLineHeight(int fontId) const;
|
||||||
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
||||||
|
|||||||
@ -29,20 +29,3 @@ uint32_t utf8NextCodepoint(const unsigned char** string) {
|
|||||||
|
|
||||||
return cp;
|
return cp;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t utf8RemoveLastChar(std::string& str) {
|
|
||||||
if (str.empty()) return 0;
|
|
||||||
size_t pos = str.size() - 1;
|
|
||||||
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
|
|
||||||
--pos;
|
|
||||||
}
|
|
||||||
str.resize(pos);
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate string by removing N UTF-8 characters from the end
|
|
||||||
void utf8TruncateChars(std::string& str, const size_t numChars) {
|
|
||||||
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
|
|
||||||
utf8RemoveLastChar(str);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
|
||||||
#define REPLACEMENT_GLYPH 0xFFFD
|
#define REPLACEMENT_GLYPH 0xFFFD
|
||||||
|
|
||||||
uint32_t utf8NextCodepoint(const unsigned char** string);
|
uint32_t utf8NextCodepoint(const unsigned char** string);
|
||||||
// Remove the last UTF-8 codepoint from a std::string and return the new size.
|
|
||||||
size_t utf8RemoveLastChar(std::string& str);
|
|
||||||
// Truncate string by removing N UTF-8 codepoints from the end.
|
|
||||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
|
||||||
|
|||||||
@ -24,13 +24,12 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
|
|||||||
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||||
|
|
||||||
void HalGPIO::startDeepSleep() {
|
void HalGPIO::startDeepSleep() {
|
||||||
|
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
||||||
while (inputMgr.isPressed(BTN_POWER)) {
|
while (inputMgr.isPressed(BTN_POWER)) {
|
||||||
delay(50);
|
delay(50);
|
||||||
inputMgr.update();
|
inputMgr.update();
|
||||||
}
|
}
|
||||||
// Arm the wakeup trigger *after* the button is released
|
|
||||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
|
||||||
// Enter Deep Sleep
|
// Enter Deep Sleep
|
||||||
esp_deep_sleep_start();
|
esp_deep_sleep_start();
|
||||||
}
|
}
|
||||||
@ -45,20 +44,12 @@ bool HalGPIO::isUsbConnected() const {
|
|||||||
return digitalRead(UART0_RXD) == HIGH;
|
return digitalRead(UART0_RXD) == HIGH;
|
||||||
}
|
}
|
||||||
|
|
||||||
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
bool HalGPIO::isWakeupByPowerButton() const {
|
||||||
const bool usbConnected = isUsbConnected();
|
|
||||||
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
||||||
const auto resetReason = esp_reset_reason();
|
const auto resetReason = esp_reset_reason();
|
||||||
|
if (isUsbConnected()) {
|
||||||
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
|
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
|
||||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
|
} else {
|
||||||
return WakeupReason::PowerButton;
|
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
|
||||||
}
|
}
|
||||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
|
|
||||||
return WakeupReason::AfterFlash;
|
|
||||||
}
|
|
||||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) {
|
|
||||||
return WakeupReason::AfterUSBPower;
|
|
||||||
}
|
|
||||||
return WakeupReason::Other;
|
|
||||||
}
|
}
|
||||||
@ -47,9 +47,8 @@ class HalGPIO {
|
|||||||
// Check if USB is connected
|
// Check if USB is connected
|
||||||
bool isUsbConnected() const;
|
bool isUsbConnected() const;
|
||||||
|
|
||||||
enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other };
|
// Check if wakeup was caused by power button press
|
||||||
|
bool isWakeupByPowerButton() const;
|
||||||
WakeupReason getWakeupReason() const;
|
|
||||||
|
|
||||||
// Button indices
|
// Button indices
|
||||||
static constexpr uint8_t BTN_BACK = 0;
|
static constexpr uint8_t BTN_BACK = 0;
|
||||||
|
|||||||
@ -1,214 +0,0 @@
|
|||||||
import sys
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
|
||||||
from collections import deque
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Try to import potentially missing packages
|
|
||||||
try:
|
|
||||||
import serial
|
|
||||||
from colorama import init, Fore, Style
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import matplotlib.animation as animation
|
|
||||||
except ImportError as e:
|
|
||||||
missing_package = e.name
|
|
||||||
print("\n" + "!" * 50)
|
|
||||||
print(f" Error: The required package '{missing_package}' is not installed.")
|
|
||||||
print("!" * 50)
|
|
||||||
|
|
||||||
print(f"\nTo fix this, please run the following command in your terminal:\n")
|
|
||||||
|
|
||||||
install_cmd = "pip install "
|
|
||||||
packages = []
|
|
||||||
if 'serial' in str(e): packages.append("pyserial")
|
|
||||||
if 'colorama' in str(e): packages.append("colorama")
|
|
||||||
if 'matplotlib' in str(e): packages.append("matplotlib")
|
|
||||||
|
|
||||||
print(f" {install_cmd}{' '.join(packages)}")
|
|
||||||
|
|
||||||
print("\nExiting...")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# --- Global Variables for Data Sharing ---
|
|
||||||
# Store last 50 data points
|
|
||||||
MAX_POINTS = 50
|
|
||||||
time_data = deque(maxlen=MAX_POINTS)
|
|
||||||
free_mem_data = deque(maxlen=MAX_POINTS)
|
|
||||||
total_mem_data = deque(maxlen=MAX_POINTS)
|
|
||||||
data_lock = threading.Lock() # Prevent reading while writing
|
|
||||||
|
|
||||||
# Initialize colors
|
|
||||||
init(autoreset=True)
|
|
||||||
|
|
||||||
def get_color_for_line(line):
|
|
||||||
"""
|
|
||||||
Classify log lines by type and assign appropriate colors.
|
|
||||||
"""
|
|
||||||
line_upper = line.upper()
|
|
||||||
|
|
||||||
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]):
|
|
||||||
return Fore.RED
|
|
||||||
if "[MEM]" in line_upper or "FREE:" in line_upper:
|
|
||||||
return Fore.CYAN
|
|
||||||
if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]):
|
|
||||||
return Fore.MAGENTA
|
|
||||||
if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]):
|
|
||||||
return Fore.GREEN
|
|
||||||
if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper:
|
|
||||||
return Fore.YELLOW
|
|
||||||
if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]):
|
|
||||||
return Fore.BLUE
|
|
||||||
if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]):
|
|
||||||
return Fore.LIGHTYELLOW_EX
|
|
||||||
if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]):
|
|
||||||
return Fore.LIGHTBLACK_EX
|
|
||||||
if "[RBS]" in line_upper:
|
|
||||||
return Fore.LIGHTCYAN_EX
|
|
||||||
if "[KRS]" in line_upper:
|
|
||||||
return Fore.LIGHTMAGENTA_EX
|
|
||||||
if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]):
|
|
||||||
return Fore.LIGHTMAGENTA_EX
|
|
||||||
if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]):
|
|
||||||
return Fore.LIGHTGREEN_EX
|
|
||||||
if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]):
|
|
||||||
return Fore.LIGHTYELLOW_EX
|
|
||||||
|
|
||||||
return Fore.WHITE
|
|
||||||
|
|
||||||
def parse_memory_line(line):
|
|
||||||
"""
|
|
||||||
Extracts Free and Total bytes from the specific log line.
|
|
||||||
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
|
|
||||||
"""
|
|
||||||
# Regex to find 'Free: <digits>' and 'Total: <digits>'
|
|
||||||
match = re.search(r"Free:\s*(\d+).*Total:\s*(\d+)", line)
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
free_bytes = int(match.group(1))
|
|
||||||
total_bytes = int(match.group(2))
|
|
||||||
return free_bytes, total_bytes
|
|
||||||
except ValueError:
|
|
||||||
return None, None
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def serial_worker(port, baud):
|
|
||||||
"""
|
|
||||||
Runs in a background thread. Handles reading serial, printing to console,
|
|
||||||
and updating the data lists.
|
|
||||||
"""
|
|
||||||
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
ser = serial.Serial(port, baud, timeout=0.1)
|
|
||||||
ser.dtr = False
|
|
||||||
ser.rts = False
|
|
||||||
except serial.SerialException as e:
|
|
||||||
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
raw_data = ser.readline().decode('utf-8', errors='replace')
|
|
||||||
|
|
||||||
if not raw_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
clean_line = raw_data.strip()
|
|
||||||
if not clean_line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add PC timestamp
|
|
||||||
pc_time = datetime.now().strftime("%H:%M:%S")
|
|
||||||
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
|
|
||||||
|
|
||||||
# Check for Memory Line
|
|
||||||
if "[MEM]" in formatted_line:
|
|
||||||
free_val, total_val = parse_memory_line(formatted_line)
|
|
||||||
if free_val is not None:
|
|
||||||
with data_lock:
|
|
||||||
time_data.append(pc_time)
|
|
||||||
free_mem_data.append(free_val / 1024) # Convert to KB
|
|
||||||
total_mem_data.append(total_val / 1024) # Convert to KB
|
|
||||||
|
|
||||||
# Print to console
|
|
||||||
line_color = get_color_for_line(formatted_line)
|
|
||||||
print(f"{line_color}{formatted_line}")
|
|
||||||
|
|
||||||
except OSError:
|
|
||||||
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}")
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
# If thread is killed violently (e.g. main exit), silence errors
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
if 'ser' in locals() and ser.is_open:
|
|
||||||
ser.close()
|
|
||||||
|
|
||||||
def update_graph(frame):
|
|
||||||
"""
|
|
||||||
Called by Matplotlib animation to redraw the chart.
|
|
||||||
"""
|
|
||||||
with data_lock:
|
|
||||||
if not time_data:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Convert deques to lists for plotting
|
|
||||||
x = list(time_data)
|
|
||||||
y_free = list(free_mem_data)
|
|
||||||
y_total = list(total_mem_data)
|
|
||||||
|
|
||||||
plt.cla() # Clear axis
|
|
||||||
|
|
||||||
# Plot Total RAM
|
|
||||||
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--')
|
|
||||||
|
|
||||||
# Plot Free RAM
|
|
||||||
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o')
|
|
||||||
|
|
||||||
# Fill area under Free RAM
|
|
||||||
plt.fill_between(x, y_free, color='green', alpha=0.1)
|
|
||||||
|
|
||||||
plt.title("ESP32 Memory Monitor")
|
|
||||||
plt.ylabel("Memory (KB)")
|
|
||||||
plt.xlabel("Time")
|
|
||||||
plt.legend(loc='upper left')
|
|
||||||
plt.grid(True, linestyle=':', alpha=0.6)
|
|
||||||
|
|
||||||
# Rotate date labels
|
|
||||||
plt.xticks(rotation=45, ha='right')
|
|
||||||
plt.tight_layout()
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
|
|
||||||
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port")
|
|
||||||
parser.add_argument("--baud", type=int, default=115200, help="Baud rate")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# 1. Start the Serial Reader in a separate thread
|
|
||||||
# Daemon=True means this thread dies when the main program closes
|
|
||||||
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
# 2. Set up the Graph (Main Thread)
|
|
||||||
try:
|
|
||||||
plt.style.use('light_background')
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
fig = plt.figure(figsize=(10, 6))
|
|
||||||
|
|
||||||
# Update graph every 1000ms
|
|
||||||
ani = animation.FuncAnimation(fig, update_graph, interval=1000)
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}")
|
|
||||||
plt.show()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
|
|
||||||
plt.close('all') # Force close any lingering plot windows
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -42,38 +42,6 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
|
|||||||
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
ScreenComponents::PopupLayout ScreenComponents::drawPopup(const GfxRenderer& renderer, const char* message) {
|
|
||||||
constexpr int margin = 15;
|
|
||||||
constexpr int y = 60;
|
|
||||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
|
|
||||||
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
||||||
const int w = textWidth + margin * 2;
|
|
||||||
const int h = textHeight + margin * 2;
|
|
||||||
const int x = (renderer.getScreenWidth() - w) / 2;
|
|
||||||
|
|
||||||
renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2
|
|
||||||
renderer.fillRect(x, y, w, h, false);
|
|
||||||
|
|
||||||
const int textX = x + (w - textWidth) / 2;
|
|
||||||
const int textY = y + margin - 2;
|
|
||||||
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD);
|
|
||||||
renderer.displayBuffer();
|
|
||||||
return {x, y, w, h};
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScreenComponents::fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, const int progress) {
|
|
||||||
constexpr int barHeight = 4;
|
|
||||||
const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width
|
|
||||||
const int barX = layout.x + (layout.width - barWidth) / 2;
|
|
||||||
const int barY = layout.y + layout.height - 10;
|
|
||||||
|
|
||||||
int fillWidth = barWidth * progress / 100;
|
|
||||||
|
|
||||||
renderer.fillRect(barX, barY, fillWidth, barHeight, true);
|
|
||||||
|
|
||||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
|
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
|
||||||
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
|
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
|
||||||
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
|
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
|
||||||
|
|||||||
@ -15,20 +15,9 @@ class ScreenComponents {
|
|||||||
public:
|
public:
|
||||||
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
|
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
|
||||||
|
|
||||||
struct PopupLayout {
|
|
||||||
int x;
|
|
||||||
int y;
|
|
||||||
int width;
|
|
||||||
int height;
|
|
||||||
};
|
|
||||||
|
|
||||||
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
|
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
|
||||||
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
|
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
|
||||||
|
|
||||||
static PopupLayout drawPopup(const GfxRenderer& renderer, const char* message);
|
|
||||||
|
|
||||||
static void fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, int progress);
|
|
||||||
|
|
||||||
// Draw a horizontal tab bar with underline indicator for selected tab
|
// Draw a horizontal tab bar with underline indicator for selected tab
|
||||||
// Returns the height of the tab bar (for positioning content below)
|
// Returns the height of the tab bar (for positioning content below)
|
||||||
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);
|
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);
|
||||||
|
|||||||
@ -8,15 +8,13 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "ScreenComponents.h"
|
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "images/CrossLarge.h"
|
#include "images/CrossLarge.h"
|
||||||
#include "util/StringUtils.h"
|
#include "util/StringUtils.h"
|
||||||
|
|
||||||
void SleepActivity::onEnter() {
|
void SleepActivity::onEnter() {
|
||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
|
renderPopup("Entering Sleep...");
|
||||||
ScreenComponents::drawPopup(renderer, "Entering Sleep...");
|
|
||||||
|
|
||||||
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
|
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
|
||||||
return renderBlankSleepScreen();
|
return renderBlankSleepScreen();
|
||||||
@ -33,6 +31,20 @@ void SleepActivity::onEnter() {
|
|||||||
renderDefaultSleepScreen();
|
renderDefaultSleepScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SleepActivity::renderPopup(const char* message) const {
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
|
||||||
|
constexpr int margin = 20;
|
||||||
|
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
|
||||||
|
constexpr int y = 117;
|
||||||
|
const int w = textWidth + margin * 2;
|
||||||
|
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
|
||||||
|
// renderer.clearScreen();
|
||||||
|
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
|
||||||
|
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
|
||||||
|
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
void SleepActivity::renderCustomSleepScreen() const {
|
void SleepActivity::renderCustomSleepScreen() const {
|
||||||
// Check if we have a /sleep directory
|
// Check if we have a /sleep directory
|
||||||
auto dir = SdMan.open("/sleep");
|
auto dir = SdMan.open("/sleep");
|
||||||
@ -238,8 +250,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
||||||
// Handle EPUB file
|
// Handle EPUB file
|
||||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
// Skip loading css since we only need metadata here
|
if (!lastEpub.load()) {
|
||||||
if (!lastEpub.load(true, true)) {
|
|
||||||
Serial.println("[SLP] Failed to load last epub");
|
Serial.println("[SLP] Failed to load last epub");
|
||||||
return renderDefaultSleepScreen();
|
return renderDefaultSleepScreen();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ class SleepActivity final : public Activity {
|
|||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void renderPopup(const char* message) const;
|
||||||
void renderDefaultSleepScreen() const;
|
void renderDefaultSleepScreen() const;
|
||||||
void renderCustomSleepScreen() const;
|
void renderCustomSleepScreen() const;
|
||||||
void renderCoverSleepScreen() const;
|
void renderCoverSleepScreen() const;
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <Utf8.h>
|
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@ -52,8 +51,7 @@ void HomeActivity::onEnter() {
|
|||||||
// If epub, try to load the metadata for title/author and cover
|
// If epub, try to load the metadata for title/author and cover
|
||||||
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
|
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
|
||||||
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
|
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
// Skip loading css since we only need metadata here
|
epub.load(false);
|
||||||
epub.load(false, true);
|
|
||||||
if (!epub.getTitle().empty()) {
|
if (!epub.getTitle().empty()) {
|
||||||
lastBookTitle = std::string(epub.getTitle());
|
lastBookTitle = std::string(epub.getTitle());
|
||||||
}
|
}
|
||||||
@ -368,7 +366,7 @@ void HomeActivity::render() {
|
|||||||
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
|
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
|
||||||
// Remove "..." first, then remove one UTF-8 char, then add "..." back
|
// Remove "..." first, then remove one UTF-8 char, then add "..." back
|
||||||
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
||||||
utf8RemoveLastChar(lines.back());
|
StringUtils::utf8RemoveLastChar(lines.back());
|
||||||
lines.back().append("...");
|
lines.back().append("...");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -377,7 +375,7 @@ void HomeActivity::render() {
|
|||||||
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
||||||
while (wordWidth > maxLineWidth && !i.empty()) {
|
while (wordWidth > maxLineWidth && !i.empty()) {
|
||||||
// Word itself is too long, trim it (UTF-8 safe)
|
// Word itself is too long, trim it (UTF-8 safe)
|
||||||
utf8RemoveLastChar(i);
|
StringUtils::utf8RemoveLastChar(i);
|
||||||
// Check if we have room for ellipsis
|
// Check if we have room for ellipsis
|
||||||
std::string withEllipsis = i + "...";
|
std::string withEllipsis = i + "...";
|
||||||
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
||||||
@ -430,7 +428,7 @@ void HomeActivity::render() {
|
|||||||
if (!lastBookAuthor.empty()) {
|
if (!lastBookAuthor.empty()) {
|
||||||
std::string trimmedAuthor = lastBookAuthor;
|
std::string trimmedAuthor = lastBookAuthor;
|
||||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||||
utf8RemoveLastChar(trimmedAuthor);
|
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||||
}
|
}
|
||||||
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
|
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
|
||||||
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
|
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
|
||||||
@ -464,14 +462,14 @@ void HomeActivity::render() {
|
|||||||
// Trim author if too long (UTF-8 safe)
|
// Trim author if too long (UTF-8 safe)
|
||||||
bool wasTrimmed = false;
|
bool wasTrimmed = false;
|
||||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||||
utf8RemoveLastChar(trimmedAuthor);
|
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||||
wasTrimmed = true;
|
wasTrimmed = true;
|
||||||
}
|
}
|
||||||
if (wasTrimmed && !trimmedAuthor.empty()) {
|
if (wasTrimmed && !trimmedAuthor.empty()) {
|
||||||
// Make room for ellipsis
|
// Make room for ellipsis
|
||||||
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
||||||
!trimmedAuthor.empty()) {
|
!trimmedAuthor.empty()) {
|
||||||
utf8RemoveLastChar(trimmedAuthor);
|
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||||
}
|
}
|
||||||
trimmedAuthor.append("...");
|
trimmedAuthor.append("...");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -266,9 +266,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
|
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
|
||||||
connectionError = "Error: General failure";
|
connectionError = "Connection failed";
|
||||||
if (status == WL_NO_SSID_AVAIL) {
|
if (status == WL_NO_SSID_AVAIL) {
|
||||||
connectionError = "Error: Network not found";
|
connectionError = "Network not found";
|
||||||
}
|
}
|
||||||
state = WifiSelectionState::CONNECTION_FAILED;
|
state = WifiSelectionState::CONNECTION_FAILED;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
@ -278,7 +278,7 @@ void WifiSelectionActivity::checkConnectionStatus() {
|
|||||||
// Check for timeout
|
// Check for timeout
|
||||||
if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) {
|
if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) {
|
||||||
WiFi.disconnect();
|
WiFi.disconnect();
|
||||||
connectionError = "Error: Connection timeout";
|
connectionError = "Connection timeout";
|
||||||
state = WifiSelectionState::CONNECTION_FAILED;
|
state = WifiSelectionState::CONNECTION_FAILED;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
return;
|
return;
|
||||||
@ -689,7 +689,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
|
|||||||
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
||||||
const auto top = (pageHeight - height * 3) / 2;
|
const auto top = (pageHeight - height * 3) / 2;
|
||||||
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
std::string ssidInfo = "Network: " + selectedSSID;
|
std::string ssidInfo = "Network: " + selectedSSID;
|
||||||
if (ssidInfo.length() > 28) {
|
if (ssidInfo.length() > 28) {
|
||||||
@ -697,7 +697,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
|
|||||||
}
|
}
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
|
||||||
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Forget network and remove saved password?");
|
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?");
|
||||||
|
|
||||||
// Draw Cancel/Forget network buttons
|
// Draw Cancel/Forget network buttons
|
||||||
const int buttonY = top + 80;
|
const int buttonY = top + 80;
|
||||||
|
|||||||
@ -130,9 +130,31 @@ void EpubReaderActivity::loop() {
|
|||||||
const int currentPage = section ? section->currentPage : 0;
|
const int currentPage = section ? section->currentPage : 0;
|
||||||
const int totalPages = section ? section->pageCount : 0;
|
const int totalPages = section ? section->pageCount : 0;
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new EpubReaderMenuActivity(
|
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||||
this->renderer, this->mappedInput, epub->getTitle(), [this]() { onReaderMenuBack(); },
|
this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages,
|
||||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
[this] {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex) {
|
||||||
|
if (currentSpineIndex != newSpineIndex) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = 0;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex, const int newPage) {
|
||||||
|
// Handle sync position
|
||||||
|
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = newPage;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,89 +242,6 @@ void EpubReaderActivity::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::onReaderMenuBack() {
|
|
||||||
exitActivity();
|
|
||||||
updateRequired = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
|
||||||
switch (action) {
|
|
||||||
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
|
||||||
// Calculate values BEFORE we start destroying things
|
|
||||||
const int currentP = section ? section->currentPage : 0;
|
|
||||||
const int totalP = section ? section->pageCount : 0;
|
|
||||||
const int spineIdx = currentSpineIndex;
|
|
||||||
const std::string path = epub->getPath();
|
|
||||||
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
|
|
||||||
// 1. Close the menu
|
|
||||||
exitActivity();
|
|
||||||
|
|
||||||
// 2. Open the Chapter Selector
|
|
||||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
|
||||||
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
|
||||||
[this] {
|
|
||||||
exitActivity();
|
|
||||||
updateRequired = true;
|
|
||||||
},
|
|
||||||
[this](const int newSpineIndex) {
|
|
||||||
if (currentSpineIndex != newSpineIndex) {
|
|
||||||
currentSpineIndex = newSpineIndex;
|
|
||||||
nextPageNumber = 0;
|
|
||||||
section.reset();
|
|
||||||
}
|
|
||||||
exitActivity();
|
|
||||||
updateRequired = true;
|
|
||||||
},
|
|
||||||
[this](const int newSpineIndex, const int newPage) {
|
|
||||||
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
|
||||||
currentSpineIndex = newSpineIndex;
|
|
||||||
nextPageNumber = newPage;
|
|
||||||
section.reset();
|
|
||||||
}
|
|
||||||
exitActivity();
|
|
||||||
updateRequired = true;
|
|
||||||
}));
|
|
||||||
|
|
||||||
xSemaphoreGive(renderingMutex);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
|
|
||||||
// 2. Trigger the reader's "Go Home" callback
|
|
||||||
if (onGoHome) {
|
|
||||||
onGoHome();
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: {
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
if (epub) {
|
|
||||||
// 2. BACKUP: Read current progress
|
|
||||||
// We use the current variables that track our position
|
|
||||||
uint16_t backupSpine = currentSpineIndex;
|
|
||||||
uint16_t backupPage = section->currentPage;
|
|
||||||
uint16_t backupPageCount = section->pageCount;
|
|
||||||
|
|
||||||
section.reset();
|
|
||||||
// 3. WIPE: Clear the cache directory
|
|
||||||
epub->clearCache();
|
|
||||||
|
|
||||||
// 4. RESTORE: Re-setup the directory and rewrite the progress file
|
|
||||||
epub->setupCacheDir();
|
|
||||||
|
|
||||||
saveProgress(backupSpine, backupPage, backupPageCount);
|
|
||||||
}
|
|
||||||
exitActivity();
|
|
||||||
updateRequired = true;
|
|
||||||
xSemaphoreGive(renderingMutex);
|
|
||||||
if (onGoHome) onGoHome();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderActivity::displayTaskLoop() {
|
void EpubReaderActivity::displayTaskLoop() {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (updateRequired) {
|
if (updateRequired) {
|
||||||
@ -369,11 +308,49 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
viewportHeight, SETTINGS.hyphenationEnabled)) {
|
viewportHeight, SETTINGS.hyphenationEnabled)) {
|
||||||
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
|
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
|
||||||
|
|
||||||
const auto popupFn = [this]() { ScreenComponents::drawPopup(renderer, "Indexing..."); };
|
// Progress bar dimensions
|
||||||
|
constexpr int barWidth = 200;
|
||||||
|
constexpr int barHeight = 10;
|
||||||
|
constexpr int boxMargin = 20;
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
|
||||||
|
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
|
||||||
|
const int boxWidthNoBar = textWidth + boxMargin * 2;
|
||||||
|
const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
|
||||||
|
const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||||
|
const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2;
|
||||||
|
const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2;
|
||||||
|
constexpr int boxY = 50;
|
||||||
|
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
|
||||||
|
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||||
|
|
||||||
|
// Always show "Indexing..." text first
|
||||||
|
{
|
||||||
|
renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false);
|
||||||
|
renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing...");
|
||||||
|
renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
pagesUntilFullRefresh = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup callback - only called for chapters >= 50KB, redraws with progress bar
|
||||||
|
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] {
|
||||||
|
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
|
||||||
|
renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
|
||||||
|
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
|
||||||
|
renderer.drawRect(barX, barY, barWidth, barHeight);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Progress callback to update progress bar
|
||||||
|
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
|
||||||
|
const int fillWidth = (barWidth - 2) * progress / 100;
|
||||||
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
};
|
||||||
|
|
||||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, SETTINGS.hyphenationEnabled, popupFn)) {
|
viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) {
|
||||||
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
|
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
|
||||||
section.reset();
|
section.reset();
|
||||||
return;
|
return;
|
||||||
@ -430,26 +407,21 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
|
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
|
||||||
}
|
}
|
||||||
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
|
||||||
FsFile f;
|
FsFile f;
|
||||||
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
||||||
uint8_t data[6];
|
uint8_t data[6];
|
||||||
data[0] = currentSpineIndex & 0xFF;
|
data[0] = currentSpineIndex & 0xFF;
|
||||||
data[1] = (currentSpineIndex >> 8) & 0xFF;
|
data[1] = (currentSpineIndex >> 8) & 0xFF;
|
||||||
data[2] = currentPage & 0xFF;
|
data[2] = section->currentPage & 0xFF;
|
||||||
data[3] = (currentPage >> 8) & 0xFF;
|
data[3] = (section->currentPage >> 8) & 0xFF;
|
||||||
data[4] = pageCount & 0xFF;
|
data[4] = section->pageCount & 0xFF;
|
||||||
data[5] = (pageCount >> 8) & 0xFF;
|
data[5] = (section->pageCount >> 8) & 0xFF;
|
||||||
f.write(data, 6);
|
f.write(data, 6);
|
||||||
f.close();
|
f.close();
|
||||||
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage);
|
|
||||||
} else {
|
|
||||||
Serial.printf("[ERS] Could not save progress!\n");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
||||||
const int orientedMarginRight, const int orientedMarginBottom,
|
const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
const int orientedMarginLeft) {
|
const int orientedMarginLeft) {
|
||||||
@ -570,8 +542,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
|||||||
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
|
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
|
||||||
titleMarginLeftAdjusted = titleMarginLeft;
|
titleMarginLeftAdjusted = titleMarginLeft;
|
||||||
}
|
}
|
||||||
if (titleWidth > availableTitleSpace) {
|
while (titleWidth > availableTitleSpace && title.length() > 11) {
|
||||||
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
|
title.replace(title.length() - 8, 8, "...");
|
||||||
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
#include <freertos/semphr.h>
|
#include <freertos/semphr.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
|
||||||
#include "EpubReaderMenuActivity.h"
|
|
||||||
#include "activities/ActivityWithSubactivity.h"
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
class EpubReaderActivity final : public ActivityWithSubactivity {
|
class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||||
@ -28,9 +27,6 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
|||||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||||
int orientedMarginBottom, int orientedMarginLeft);
|
int orientedMarginBottom, int orientedMarginLeft);
|
||||||
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
|
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
|
||||||
void saveProgress(int spineIndex, int currentPage, int pageCount);
|
|
||||||
void onReaderMenuBack();
|
|
||||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
|
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
|
||||||
|
|||||||
@ -181,7 +181,9 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
|
|||||||
const int pageItems = getPageItems();
|
const int pageItems = getPageItems();
|
||||||
const int totalItems = getTotalItems();
|
const int totalItems = getTotalItems();
|
||||||
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Go to Chapter", true, EpdFontFamily::BOLD);
|
const std::string title =
|
||||||
|
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||||
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
||||||
@ -206,11 +208,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip button hints in landscape CW mode (they overlap content)
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
||||||
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,103 +0,0 @@
|
|||||||
#include "EpubReaderMenuActivity.h"
|
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
|
|
||||||
#include "fontIds.h"
|
|
||||||
|
|
||||||
void EpubReaderMenuActivity::onEnter() {
|
|
||||||
ActivityWithSubactivity::onEnter();
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
|
||||||
updateRequired = true;
|
|
||||||
|
|
||||||
xTaskCreate(&EpubReaderMenuActivity::taskTrampoline, "EpubMenuTask", 4096, this, 1, &displayTaskHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderMenuActivity::onExit() {
|
|
||||||
ActivityWithSubactivity::onExit();
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
if (displayTaskHandle) {
|
|
||||||
vTaskDelete(displayTaskHandle);
|
|
||||||
displayTaskHandle = nullptr;
|
|
||||||
}
|
|
||||||
vSemaphoreDelete(renderingMutex);
|
|
||||||
renderingMutex = nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderMenuActivity::taskTrampoline(void* param) {
|
|
||||||
auto* self = static_cast<EpubReaderMenuActivity*>(param);
|
|
||||||
self->displayTaskLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderMenuActivity::displayTaskLoop() {
|
|
||||||
while (true) {
|
|
||||||
if (updateRequired && !subActivity) {
|
|
||||||
updateRequired = false;
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
renderScreen();
|
|
||||||
xSemaphoreGive(renderingMutex);
|
|
||||||
}
|
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderMenuActivity::loop() {
|
|
||||||
if (subActivity) {
|
|
||||||
subActivity->loop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use local variables for items we need to check after potential deletion
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
|
||||||
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
|
|
||||||
updateRequired = true;
|
|
||||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
|
||||||
selectedIndex = (selectedIndex + 1) % menuItems.size();
|
|
||||||
updateRequired = true;
|
|
||||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
||||||
// 1. Capture the callback and action locally
|
|
||||||
auto actionCallback = onAction;
|
|
||||||
auto selectedAction = menuItems[selectedIndex].action;
|
|
||||||
|
|
||||||
// 2. Execute the callback
|
|
||||||
actionCallback(selectedAction);
|
|
||||||
|
|
||||||
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
|
|
||||||
return;
|
|
||||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
||||||
onBack();
|
|
||||||
return; // Also return here just in case
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderMenuActivity::renderScreen() {
|
|
||||||
renderer.clearScreen();
|
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
|
||||||
|
|
||||||
// Title
|
|
||||||
const std::string truncTitle =
|
|
||||||
renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - 40, EpdFontFamily::BOLD);
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, truncTitle.c_str(), true, EpdFontFamily::BOLD);
|
|
||||||
|
|
||||||
// Menu Items
|
|
||||||
constexpr int startY = 60;
|
|
||||||
constexpr int lineHeight = 30;
|
|
||||||
|
|
||||||
for (size_t i = 0; i < menuItems.size(); ++i) {
|
|
||||||
const int displayY = startY + (i * lineHeight);
|
|
||||||
const bool isSelected = (static_cast<int>(i) == selectedIndex);
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
renderer.fillRect(0, displayY, pageWidth - 1, lineHeight, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.drawText(UI_10_FONT_ID, 20, displayY, menuItems[i].label.c_str(), !isSelected);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer / Hints
|
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
||||||
|
|
||||||
renderer.displayBuffer();
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <Epub.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/semphr.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "../ActivityWithSubactivity.h"
|
|
||||||
#include "MappedInputManager.h"
|
|
||||||
|
|
||||||
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|
||||||
public:
|
|
||||||
enum class MenuAction { SELECT_CHAPTER, GO_HOME, DELETE_CACHE };
|
|
||||||
|
|
||||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
|
||||||
const std::function<void()>& onBack, const std::function<void(MenuAction)>& onAction)
|
|
||||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
|
||||||
title(title),
|
|
||||||
onBack(onBack),
|
|
||||||
onAction(onAction) {}
|
|
||||||
|
|
||||||
void onEnter() override;
|
|
||||||
void onExit() override;
|
|
||||||
void loop() override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct MenuItem {
|
|
||||||
MenuAction action;
|
|
||||||
std::string label;
|
|
||||||
};
|
|
||||||
|
|
||||||
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"},
|
|
||||||
{MenuAction::GO_HOME, "Go Home"},
|
|
||||||
{MenuAction::DELETE_CACHE, "Delete Book Cache"}};
|
|
||||||
|
|
||||||
int selectedIndex = 0;
|
|
||||||
bool updateRequired = false;
|
|
||||||
TaskHandle_t displayTaskHandle = nullptr;
|
|
||||||
SemaphoreHandle_t renderingMutex = nullptr;
|
|
||||||
std::string title = "Reader Menu";
|
|
||||||
|
|
||||||
const std::function<void()> onBack;
|
|
||||||
const std::function<void(MenuAction)> onAction;
|
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
|
||||||
[[noreturn]] void displayTaskLoop();
|
|
||||||
void renderScreen();
|
|
||||||
};
|
|
||||||
@ -207,10 +207,28 @@ void TxtReaderActivity::buildPageIndex() {
|
|||||||
|
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
const size_t fileSize = txt->getFileSize();
|
const size_t fileSize = txt->getFileSize();
|
||||||
|
int lastProgressPercent = -1;
|
||||||
|
|
||||||
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
|
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
|
||||||
|
|
||||||
ScreenComponents::drawPopup(renderer, "Indexing...");
|
// Progress bar dimensions (matching EpubReaderActivity style)
|
||||||
|
constexpr int barWidth = 200;
|
||||||
|
constexpr int barHeight = 10;
|
||||||
|
constexpr int boxMargin = 20;
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
|
||||||
|
const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
|
||||||
|
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
|
||||||
|
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
|
||||||
|
constexpr int boxY = 50;
|
||||||
|
const int barX = boxX + (boxWidth - barWidth) / 2;
|
||||||
|
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||||
|
|
||||||
|
// Draw initial progress box
|
||||||
|
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||||
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing...");
|
||||||
|
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||||
|
renderer.drawRect(barX, barY, barWidth, barHeight);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
|
||||||
while (offset < fileSize) {
|
while (offset < fileSize) {
|
||||||
std::vector<std::string> tempLines;
|
std::vector<std::string> tempLines;
|
||||||
@ -230,6 +248,17 @@ void TxtReaderActivity::buildPageIndex() {
|
|||||||
pageOffsets.push_back(offset);
|
pageOffsets.push_back(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update progress bar every 10% (matching EpubReaderActivity logic)
|
||||||
|
int progressPercent = (offset * 100) / fileSize;
|
||||||
|
if (lastProgressPercent / 10 != progressPercent / 10) {
|
||||||
|
lastProgressPercent = progressPercent;
|
||||||
|
|
||||||
|
// Fill progress bar
|
||||||
|
const int fillWidth = (barWidth - 2) * progressPercent / 100;
|
||||||
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
// Yield to other tasks periodically
|
// Yield to other tasks periodically
|
||||||
if (pageOffsets.size() % 20 == 0) {
|
if (pageOffsets.size() % 20 == 0) {
|
||||||
vTaskDelay(1);
|
vTaskDelay(1);
|
||||||
@ -373,6 +402,9 @@ void TxtReaderActivity::renderScreen() {
|
|||||||
|
|
||||||
// Initialize reader if not done
|
// Initialize reader if not done
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
initializeReader();
|
initializeReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -533,8 +565,8 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
|
|||||||
|
|
||||||
std::string title = txt->getTitle();
|
std::string title = txt->getTitle();
|
||||||
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
if (titleWidth > availableTextWidth) {
|
while (titleWidth > availableTextWidth && title.length() > 11) {
|
||||||
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth);
|
title.replace(title.length() - 8, 8, "...");
|
||||||
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -149,11 +149,8 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
|
|||||||
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
|
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip button hints in landscape CW mode (they overlap content)
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
||||||
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/main.cpp
21
src/main.cpp
@ -294,22 +294,10 @@ void setup() {
|
|||||||
SETTINGS.loadFromFile();
|
SETTINGS.loadFromFile();
|
||||||
KOREADER_STORE.loadFromFile();
|
KOREADER_STORE.loadFromFile();
|
||||||
|
|
||||||
switch (gpio.getWakeupReason()) {
|
if (gpio.isWakeupByPowerButton()) {
|
||||||
case HalGPIO::WakeupReason::PowerButton:
|
// For normal wakeups, verify power button press duration
|
||||||
// For normal wakeups, verify power button press duration
|
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
|
||||||
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
|
verifyPowerButtonDuration();
|
||||||
verifyPowerButtonDuration();
|
|
||||||
break;
|
|
||||||
case HalGPIO::WakeupReason::AfterUSBPower:
|
|
||||||
// If USB power caused a cold boot, go back to sleep
|
|
||||||
Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis());
|
|
||||||
gpio.startDeepSleep();
|
|
||||||
break;
|
|
||||||
case HalGPIO::WakeupReason::AfterFlash:
|
|
||||||
// After flashing, just proceed to boot
|
|
||||||
case HalGPIO::WakeupReason::Other:
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
||||||
@ -329,6 +317,7 @@ void setup() {
|
|||||||
// Clear app state to avoid getting into a boot loop if the epub doesn't load
|
// Clear app state to avoid getting into a boot loop if the epub doesn't load
|
||||||
const auto path = APP_STATE.openEpubPath;
|
const auto path = APP_STATE.openEpubPath;
|
||||||
APP_STATE.openEpubPath = "";
|
APP_STATE.openEpubPath = "";
|
||||||
|
APP_STATE.lastSleepImage = 0;
|
||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
onGoToReader(path, MyLibraryActivity::Tab::Recent);
|
onGoToReader(path, MyLibraryActivity::Tab::Recent);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1146,10 +1146,10 @@ function retryAllFailedUploads() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate folder name
|
// Validate folder name (no special characters except underscore and hyphen)
|
||||||
const validName = /^(?!\.{1,2}$)[^"*:<>?\/\\|]+$/.test(folderName);
|
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
|
||||||
if (!validName) {
|
if (!validName) {
|
||||||
alert('Folder name cannot contain \" * : < > ? / \\ | and must not be . or ..');
|
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,4 +61,23 @@ bool checkFileExtension(const String& fileName, const char* extension) {
|
|||||||
return localFile.endsWith(localExtension);
|
return localFile.endsWith(localExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_t utf8RemoveLastChar(std::string& str) {
|
||||||
|
if (str.empty()) return 0;
|
||||||
|
size_t pos = str.size() - 1;
|
||||||
|
// Walk back to find the start of the last UTF-8 character
|
||||||
|
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
|
||||||
|
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
|
||||||
|
--pos;
|
||||||
|
}
|
||||||
|
str.resize(pos);
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate string by removing N UTF-8 characters from the end
|
||||||
|
void utf8TruncateChars(std::string& str, const size_t numChars) {
|
||||||
|
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
|
||||||
|
utf8RemoveLastChar(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace StringUtils
|
} // namespace StringUtils
|
||||||
|
|||||||
@ -19,4 +19,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
|
|||||||
bool checkFileExtension(const std::string& fileName, const char* extension);
|
bool checkFileExtension(const std::string& fileName, const char* extension);
|
||||||
bool checkFileExtension(const String& fileName, const char* extension);
|
bool checkFileExtension(const String& fileName, const char* extension);
|
||||||
|
|
||||||
|
// UTF-8 safe string truncation - removes one character from the end
|
||||||
|
// Returns the new size after removing one UTF-8 character
|
||||||
|
size_t utf8RemoveLastChar(std::string& str);
|
||||||
|
|
||||||
|
// Truncate string by removing N UTF-8 characters from the end
|
||||||
|
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||||
} // namespace StringUtils
|
} // namespace StringUtils
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user