Compare commits

...

13 Commits

Author SHA1 Message Date
Jake Kenneally
2f8a40d09c Merge branch 'master' into feature/add-epub-css-parsing
* master:
  fix: Correct `debugging_monitor.py` script instructions (#676)
  fix: Correct instruction text to match actual button text (#672)
  fix: Increase network SSID display length (#670)
2026-02-03 20:21:04 -05:00
Jake Kenneally
9e1356bb92 perf: skip CSS loading in Sleep and Home activities
Both activities only need book metadata (title, author) and cover image.
Pass skipLoadingCss=true to Epub::load() to avoid unnecessary CSS
parsing and caching operations.
2026-02-03 20:01:04 -05:00
Jake Kenneally
cd61b263e5 feat: integrate CSS rules caching in Epub loader
- Add cssFiles member to Epub class (moved from BookMetadataCache)
- Add getCssRulesCache() and loadCssRulesFromCache() methods
- Update parseCssFiles() to save parsed rules to cache
- Try loading from css_rules.cache before parsing CSS files
- Add skipLoadingCss parameter to Epub::load() for performance
- Remove cssFiles from BookMetadataCache (no longer needed)
- Revert BookMetadataCache version to 5 (pre-CSS-files format)

When loading an EPUB:
1. Try to load cached CSS rules first
2. If cache miss, parse CSS files and save to cache
3. If skipLoadingCss=true, skip CSS entirely (for cover display)
2026-02-03 20:01:04 -05:00
Jake Kenneally
6115bf3cd2 feat: add CSS rules caching to CssParser
Add saveToCache() and loadFromCache() methods to CssParser for persisting
parsed CSS rules to disk. The cache format includes:
- Version byte for cache invalidation
- Rule count
- For each rule: length-prefixed selector string + CssStyle fields

This allows skipping CSS file parsing on subsequent book opens by loading
pre-parsed rules from cache.
2026-02-03 20:01:04 -05:00
Jake Kenneally
a7ffc02c34 refactor: simplify CssParser margin/padding shorthand
Remove intermediary variables (top, right, bottom, left) in margin and
padding shorthand parsing. Directly assign to style fields and reference
previously assigned values for defaulting logic.

No functional change - purely code simplification.
2026-02-03 20:01:04 -05:00
Jake Kenneally
d564173949 refactor: merge TextBlock::Style into BlockStyle; use bitflag underlines
Major consolidation of styling infrastructure:

- Remove TextBlock::Style enum (JUSTIFIED, LEFT_ALIGN, etc.)
  Alignment is now stored in BlockStyle.alignment using CssTextAlign

- Remove wordUnderlines list from TextBlock and ParsedText
  Underline state is now encoded in EpdFontFamily::Style via UNDERLINE bitflag

- Use BlockStyle::fromCssStyle() and getCombinedBlockStyle() in parser
  Removes duplicated createBlockStyleFromCss() and mergeBlockStyles()

- Simplify text block rendering to check style bitflag for underlines

- Revert spurious spaces handling (isAttachingPunctuation logic)
  The actualGapCount approach had issues; using standard word gaps

This reduces code duplication and simplifies the style inheritance model.
2026-02-03 20:01:04 -05:00
Jake Kenneally
f0f6618246 feat: add BlockStyle conversion and combination methods
- Add alignment and textAlignDefined fields to BlockStyle
- Add getCombinedBlockStyle(child) for merging parent/child styles
- Add static fromCssStyle(cssStyle, emSize, paragraphAlignment) factory

These methods centralize the CSS→BlockStyle conversion logic (previously
duplicated in createBlockStyleFromCss) and provide a clean API for
handling nested block element style inheritance.
2026-02-03 20:00:54 -05:00
Jake Kenneally
0fa4bb4100 refactor: rename CssStyle properties to match CSS naming
- TextAlign enum → CssTextAlign (reordered to match settings: Justify=0, Left=1, Center=2, Right=3)
- alignment → textAlign
- indent → textIndent
- decoration → textDecoration
- Update CssPropertyFlags field names to match
- Remove TextAlign::None; default to CssTextAlign::Left

This aligns internal naming with actual CSS property names for clarity.
2026-02-03 20:00:54 -05:00
Jake Kenneally
53931b3693 feat: add UNDERLINE bitflag to EpdFontFamily::Style
Convert Style enum to true bitflags by adding UNDERLINE=4. Update getFont()
to use bitwise operations instead of equality checks, allowing styles like
BOLD|UNDERLINE to work correctly.

This is preparation for encoding underline state directly in the Style
rather than tracking it separately.
2026-02-03 19:39:58 -05:00
Jake Kenneally
9d58952ba7 refactor: rename getIndentWidth to getTextAdvanceX
The function measures the advance width of arbitrary text (specifically
em-space prefix), not just indentation. getTextAdvanceX better reflects
its actual purpose.
2026-02-03 19:39:25 -05:00
Jake Kenneally
78d6e5931c
fix: Correct debugging_monitor.py script instructions (#676)
Some checks are pending
CI / build (push) Waiting to run
## Summary

**What is the goal of this PR?**
- Minor correction to the `debugging_monitor.py` script instructions

**What changes are included?**
- `pyserial` should be installed, NOT `serial`, which is a [different
lib](https://pypi.org/project/serial/)
- Added macOS serial port

## Additional Context

- Just a minor docs update. I can confirm the debugging script is
working great on macOS

---

### AI Usage

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

Did you use AI tools to help write this code? _**< NO >**_
2026-02-04 00:33:20 +03:00
Luke Stein
dac11c3fdd
fix: Correct instruction text to match actual button text (#672)
## Summary

* Instruction text says "Press OK to scan again" but button label is
actually "Connect" (not OK)
* Corrects instruction text

---

### AI Usage

Did you use AI tools to help write this code? **No**
2026-02-04 00:32:52 +03:00
Aaron Cunliffe
d403044f76
fix: Increase network SSID display length (#670)
Some checks are pending
CI / build (push) Waiting to run
## Rationale 

I have 2 wifi access points with almost identical names, just one has
`_EXT` at the end of it. With the current display limit of 13 characters
before adding ellipsis, I can't tell which is which.

Before device screenshot with masked SSIDs:
<img
src="https://github.com/user-attachments/assets/3c5cbbaa-b2f6-412f-b5a8-6278963bd0f2"
width="300">


## Summary

Adjusted displayed length from 13 characters to 30 in the Wifi selection
screen - I've left some space for potential proportional font changes in
the future

After image with masked SSIDs:
<img
src="https://github.com/user-attachments/assets/c5f0712b-bbd3-4eec-9820-4693fae90c9f"
width="300">

---

### AI Usage

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

Did you use AI tools to help write this code? _**< NO >**_
2026-02-03 18:24:23 +03:00
22 changed files with 555 additions and 487 deletions

View File

@ -102,13 +102,18 @@ After flashing the new features, its recommended to capture detailed logs fro
First, make sure all required Python packages are installed:
```python
python3 -m pip install serial colorama matplotlib
python3 -m pip install pyserial colorama matplotlib
```
after that run the script:
```sh
# For Linux
# This was tested on Debian and should work on most Linux systems.
python3 scripts/debugging_monitor.py
# For macOS
python3 scripts/debugging_monitor.py /dev/cu.usbmodem2101
```
This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS.
Minor adjustments may be required for Windows.
## Internals

View File

@ -1,23 +1,19 @@
#include "EpdFontFamily.h"
const EpdFont* EpdFontFamily::getFont(const Style style) const {
if (style == BOLD && bold) {
// Extract font style bits (ignore UNDERLINE bit for font selection)
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;
}
if (style == ITALIC && italic) {
} else if (hasItalic && italic) {
return italic;
}
if (style == BOLD_ITALIC) {
if (boldItalic) {
return boldItalic;
}
if (bold) {
return bold;
}
if (italic) {
return italic;
}
}
return regular;
}

View File

@ -3,7 +3,7 @@
class EpdFontFamily {
public:
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3 };
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4 };
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)

View File

@ -86,8 +86,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
tocNavItem = opfParser.tocNavPath;
}
// Copy CSS files to metadata
bookMetadata.cssFiles = opfParser.cssFiles;
if (!opfParser.cssFiles.empty()) {
cssFiles = opfParser.cssFiles;
}
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
return true;
@ -207,21 +208,30 @@ bool Epub::parseTocNavFile() const {
return true;
}
bool Epub::parseCssFiles() {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot parse CSS, cache not loaded\n", millis());
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
bool Epub::loadCssRulesFromCache() const {
FsFile cssCacheFile;
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;
}
// Always create CssParser - needed for inline style parsing even without CSS files
cssParser.reset(new CssParser());
const auto& cssFiles = bookMetadataCache->coreMetadata.cssFiles;
void Epub::parseCssFiles() const {
if (cssFiles.empty()) {
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
if (!loadCssRulesFromCache()) {
// Cache miss - parse CSS files
for (const auto& cssPath : cssFiles) {
Serial.printf("[%lu] [EBP] Parsing CSS file: %s\n", millis(), cssPath.c_str());
@ -251,22 +261,38 @@ bool Epub::parseCssFiles() {
SdMan.remove(tmpCssPath.c_str());
}
// Save to cache for next time
FsFile cssCacheFile;
if (SdMan.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
cssParser->saveToCache(cssCacheFile);
cssCacheFile.close();
}
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
bool Epub::load(const bool buildIfMissing) {
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
// Initialize spine/TOC cache
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
if (bookMetadataCache->load()) {
// Parse CSS files from loaded cache
if (!skipLoadingCss && !loadCssRulesFromCache()) {
Serial.printf("[%lu] [EBP] Warning: CSS rules cache not found, attempting to parse CSS files\n", millis());
// 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());
return true;
}
@ -363,8 +389,10 @@ bool Epub::load(const bool buildIfMissing) {
return false;
}
if (!skipLoadingCss) {
// Parse CSS files after cache reload
parseCssFiles();
}
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
return true;

View File

@ -27,12 +27,16 @@ class Epub {
std::unique_ptr<BookMetadataCache> bookMetadataCache;
// CSS parser for styling
std::unique_ptr<CssParser> cssParser;
// CSS files
std::vector<std::string> cssFiles;
bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseTocNcxFile() const;
bool parseTocNavFile() const;
bool parseCssFiles();
void parseCssFiles() const;
std::string getCssRulesCache() const;
bool loadCssRulesFromCache() const;
public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
@ -41,7 +45,7 @@ class Epub {
}
~Epub() = default;
std::string& getBasePath() { return contentBasePath; }
bool load(bool buildIfMissing = true);
bool load(bool buildIfMissing = true, bool skipLoadingCss = false);
bool clearCache() const;
void setupCacheDir() const;
const std::string& getCachePath() const;

View File

@ -9,7 +9,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 6;
constexpr uint8_t BOOK_CACHE_VERSION = 5;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
@ -115,14 +115,9 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
constexpr uint32_t headerASize =
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() +
metadata.coverItemHref.size() + metadata.textReferenceHref.size() +
sizeof(uint32_t) * 5 + cssFilesSize;
sizeof(uint32_t) * 5;
const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount;
const uint32_t lutOffset = headerASize + metadataSize;
@ -137,11 +132,6 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
serialization::writeString(bookFile, metadata.language);
serialization::writeString(bookFile, metadata.coverItemHref);
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
spineFile.seek(0);
@ -395,16 +385,6 @@ bool BookMetadataCache::load() {
serialization::readString(bookFile, coreMetadata.language);
serialization::readString(bookFile, coreMetadata.coverItemHref);
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;
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount);

View File

@ -14,7 +14,6 @@ class BookMetadataCache {
std::string language;
std::string coverItemHref;
std::string textReferenceHref;
std::vector<std::string> cssFiles;
};
struct SpineEntry {

View File

@ -19,23 +19,6 @@ namespace {
constexpr char SOFT_HYPHEN_UTF8[] = "\xC2\xAD";
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; }
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
@ -66,12 +49,15 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
} // namespace
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline) {
void ParsedText::addWord(std::string word, const EpdFontFamily::Style style, const bool underline) {
if (word.empty()) return;
words.push_back(std::move(word));
wordStyles.push_back(fontStyle);
wordUnderlines.push_back(underline);
EpdFontFamily::Style combinedStyle = style;
if (underline) {
combinedStyle = static_cast<EpdFontFamily::Style>(combinedStyle | EpdFontFamily::UNDERLINE);
}
wordStyles.push_back(combinedStyle);
}
// Consumes data to minimize memory usage
@ -112,8 +98,7 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
auto wordStylesIt = wordStyles.begin();
while (wordsIt != words.end()) {
uint16_t width = measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt);
wordWidths.push_back(width);
wordWidths.push_back(measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt));
std::advance(wordsIt, 1);
std::advance(wordStylesIt, 1);
@ -129,8 +114,9 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
}
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
const int firstLineIndent = blockStyle.textIndent > 0 && !extraParagraphSpacing &&
(style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN)
const int firstLineIndent =
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@ -233,7 +219,7 @@ void ParsedText::applyParagraphIndent() {
if (blockStyle.textIndentDefined) {
// CSS text-indent is explicitly set (even if 0) - don't use fallback EmSpace
// The actual indent positioning is handled in extractLine()
} else if (style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) {
} else if (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) {
// No CSS text-indent defined - use EmSpace fallback for visual indent
words.front().insert(0, "\xe2\x80\x83");
}
@ -244,8 +230,9 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
const int pageWidth, const int spaceWidth,
std::vector<uint16_t>& wordWidths) {
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
const int firstLineIndent = blockStyle.textIndent > 0 && !extraParagraphSpacing &&
(style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN)
const int firstLineIndent =
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@ -381,25 +368,16 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
const bool isFirstLine = breakIndex == 0;
const int firstLineIndent = isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing &&
(style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN)
const int firstLineIndent =
isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
// 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
// Calculate total word width for this line
int lineWordWidthSum = 0;
size_t actualGapCount = 0;
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;
for (size_t i = lastBreakAt; i < lineBreak; i++) {
lineWordWidthSum += wordWidths[i];
}
// Calculate spacing (account for indent reducing effective page width on first line)
@ -409,54 +387,37 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
int spacing = spaceWidth;
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
// For justified text, calculate spacing based on actual gap count
if (style == TextBlock::JUSTIFIED && !isLastLine && actualGapCount >= 1) {
spacing = spareSpace / static_cast<int>(actualGapCount);
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && lineWordCount >= 2) {
spacing = spareSpace / (lineWordCount - 1);
}
// Calculate initial x position (first line starts at indent for left/justified text)
auto xpos = static_cast<uint16_t>(firstLineIndent);
if (style == TextBlock::RIGHT_ALIGN) {
xpos = spareSpace - static_cast<int>(actualGapCount) * spaceWidth;
} else if (style == TextBlock::CENTER_ALIGN) {
xpos = (spareSpace - static_cast<int>(actualGapCount) * spaceWidth) / 2;
if (blockStyle.alignment == CssTextAlign::Right) {
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
} else if (blockStyle.alignment == CssTextAlign::Center) {
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
}
// 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;
auto wordIt = words.begin();
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
for (size_t i = lastBreakAt; i < lineBreak; i++) {
const uint16_t currentWordWidth = wordWidths[i];
lineXPos.push_back(xpos);
// 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;
xpos += currentWordWidth + spacing;
}
// Iterators always start at the beginning as we are moving content with splice below
auto wordEndIt = words.begin();
auto wordStyleEndIt = wordStyles.begin();
auto wordUnderlineEndIt = wordUnderlines.begin();
std::advance(wordEndIt, lineWordCount);
std::advance(wordStyleEndIt, lineWordCount);
std::advance(wordUnderlineEndIt, lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
std::list<bool> lineWordUnderlines;
lineWordUnderlines.splice(lineWordUnderlines.begin(), wordUnderlines, wordUnderlines.begin(), wordUnderlineEndIt);
for (auto& word : lineWords) {
if (containsSoftHyphen(word)) {
@ -464,6 +425,6 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
}
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style,
blockStyle, std::move(lineWordUnderlines)));
processLine(
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle));
}

View File

@ -16,8 +16,6 @@ class GfxRenderer;
class ParsedText {
std::list<std::string> words;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordUnderlines; // Track underline per word
TextBlock::Style style;
BlockStyle blockStyle;
bool extraParagraphSpacing;
bool hyphenationEnabled;
@ -35,19 +33,14 @@ class ParsedText {
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
public:
explicit ParsedText(const TextBlock::Style style, const bool extraParagraphSpacing,
const bool hyphenationEnabled = false, const BlockStyle& blockStyle = BlockStyle())
: style(style),
blockStyle(blockStyle),
extraParagraphSpacing(extraParagraphSpacing),
hyphenationEnabled(hyphenationEnabled) {}
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
const BlockStyle& blockStyle = BlockStyle())
: blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
~ParsedText() = default;
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; }
TextBlock::Style getStyle() const { return style; }
const BlockStyle& getBlockStyle() const { return blockStyle; }
BlockStyle& getBlockStyle() { return blockStyle; }
size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,

View File

@ -2,26 +2,89 @@
#include <cstdint>
#include "Epub/css/CssStyle.h"
/**
* 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.
* BlockStyle - Block-level styling properties
*/
struct BlockStyle {
int16_t marginTop = 0; // pixels
int16_t marginBottom = 0; // pixels
int16_t marginLeft = 0; // pixels
int16_t marginRight = 0; // pixels
int16_t paddingTop = 0; // pixels (treated same as margin)
int16_t paddingBottom = 0; // pixels (treated same as margin)
int16_t paddingLeft = 0; // pixels (treated same as margin)
int16_t paddingRight = 0; // pixels (treated same as margin)
int16_t textIndent = 0; // pixels
CssTextAlign alignment = CssTextAlign::Justify;
// Spacing (in pixels)
int16_t marginTop = 0;
int16_t marginBottom = 0;
int16_t marginLeft = 0;
int16_t marginRight = 0;
int16_t paddingTop = 0; // treated same as margin for rendering
int16_t paddingBottom = 0; // treated same as margin for rendering
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 textAlignDefined = false; // true if text-align was explicitly set in CSS
// Combined horizontal insets (margin + padding)
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
[[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;
}
};

View File

@ -14,15 +14,14 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin();
auto wordUnderlineIt = wordUnderlines.begin();
for (size_t i = 0; i < words.size(); i++) {
const int wordX = *wordXposIt + x;
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, *wordStylesIt);
const EpdFontFamily::Style currentStyle = *wordStylesIt;
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle);
// Draw underline if word is underlined
if (wordUnderlineIt != wordUnderlines.end() && *wordUnderlineIt) {
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
const std::string& w = *wordIt;
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), *wordStylesIt);
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
// 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;
@ -33,8 +32,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 &&
static_cast<uint8_t>(w[2]) == 0x83) {
const char* visiblePtr = w.c_str() + 3;
const int prefixWidth = renderer.getIndentWidth(fontId, std::string("\xe2\x80\x83").c_str());
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, *wordStylesIt);
const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str());
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
startX = wordX + prefixWidth;
underlineWidth = visibleWidth;
}
@ -45,9 +44,6 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
std::advance(wordIt, 1);
std::advance(wordStylesIt, 1);
std::advance(wordXposIt, 1);
if (wordUnderlineIt != wordUnderlines.end()) {
std::advance(wordUnderlineIt, 1);
}
}
}
@ -64,29 +60,9 @@ bool TextBlock::serialize(FsFile& file) const {
for (auto x : wordXpos) serialization::writePod(file, x);
for (auto s : wordStyles) serialization::writePod(file, s);
// Underline flags (packed as bytes, 8 words per byte)
uint8_t underlineByte = 0;
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)
// Style (alignment + margins/padding/indent)
serialization::writePod(file, blockStyle.alignment);
serialization::writePod(file, blockStyle.textAlignDefined);
serialization::writePod(file, blockStyle.marginTop);
serialization::writePod(file, blockStyle.marginBottom);
serialization::writePod(file, blockStyle.marginLeft);
@ -106,8 +82,6 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordUnderlines;
Style style;
BlockStyle blockStyle;
// Word count
@ -127,23 +101,9 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
for (auto& x : wordXpos) serialization::readPod(file, x);
for (auto& s : wordStyles) serialization::readPod(file, s);
// Underline flags (packed as bytes, 8 words per byte)
wordUnderlines.resize(wc, false);
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)
// Style (alignment + margins/padding/indent)
serialization::readPod(file, blockStyle.alignment);
serialization::readPod(file, blockStyle.textAlignDefined);
serialization::readPod(file, blockStyle.marginTop);
serialization::readPod(file, blockStyle.marginBottom);
serialization::readPod(file, blockStyle.marginLeft);
@ -155,6 +115,6 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
serialization::readPod(file, blockStyle.textIndent);
serialization::readPod(file, blockStyle.textIndentDefined);
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style,
blockStyle, std::move(wordUnderlines)));
return std::unique_ptr<TextBlock>(
new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), blockStyle));
}

View File

@ -11,41 +11,21 @@
// Represents a line of text on a page
class TextBlock final : public Block {
public:
enum Style : uint8_t {
JUSTIFIED = 0,
LEFT_ALIGN = 1,
CENTER_ALIGN = 2,
RIGHT_ALIGN = 3,
};
private:
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordUnderlines; // Track underline per word
Style style;
BlockStyle blockStyle;
public:
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
std::list<EpdFontFamily::Style> word_styles, const Style style,
const BlockStyle& blockStyle = BlockStyle(), std::list<bool> word_underlines = std::list<bool>())
std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
wordStyles(std::move(word_styles)),
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);
}
}
blockStyle(blockStyle) {}
~TextBlock() override = default;
void setStyle(const Style style) { this->style = style; }
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
Style getStyle() const { return style; }
const BlockStyle& getBlockStyle() const { return blockStyle; }
bool isEmpty() override { return words.empty(); }
void layout(GfxRenderer& renderer) override {};

View File

@ -204,15 +204,15 @@ std::vector<std::string> CssParser::splitWhitespace(const std::string& s) {
// Property value interpreters
TextAlign CssParser::interpretAlignment(const std::string& val) {
CssTextAlign CssParser::interpretAlignment(const std::string& val) {
const std::string v = normalized(val);
if (v == "left" || v == "start") return TextAlign::Left;
if (v == "right" || v == "end") return TextAlign::Right;
if (v == "center") return TextAlign::Center;
if (v == "justify") return TextAlign::Justify;
if (v == "left" || v == "start") return CssTextAlign::Left;
if (v == "right" || v == "end") return CssTextAlign::Right;
if (v == "center") return CssTextAlign::Center;
if (v == "justify") return CssTextAlign::Justify;
return TextAlign::None;
return CssTextAlign::Left;
}
CssFontStyle CssParser::interpretFontStyle(const std::string& val) {
@ -352,11 +352,8 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
// Match property and set value
if (propName == "text-align") {
const TextAlign align = interpretAlignment(propValue);
if (align != TextAlign::None) {
style.alignment = align;
style.defined.alignment = 1;
}
style.textAlign = interpretAlignment(propValue);
style.defined.textAlign = 1;
} else if (propName == "font-style") {
style.fontStyle = interpretFontStyle(propValue);
style.defined.fontStyle = 1;
@ -364,11 +361,11 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
style.fontWeight = interpretFontWeight(propValue);
style.defined.fontWeight = 1;
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
style.decoration = interpretDecoration(propValue);
style.defined.decoration = 1;
style.textDecoration = interpretDecoration(propValue);
style.defined.textDecoration = 1;
} else if (propName == "text-indent") {
style.indent = interpretLength(propValue);
style.defined.indent = 1;
style.textIndent = interpretLength(propValue);
style.defined.textIndent = 1;
} else if (propName == "margin-top") {
style.marginTop = interpretLength(propValue);
style.defined.marginTop = 1;
@ -385,14 +382,10 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
// Shorthand: 1-4 values for top, right, bottom, left
const auto values = splitWhitespace(propValue);
if (!values.empty()) {
const CssLength top = interpretLength(values[0]);
const CssLength right = values.size() >= 2 ? interpretLength(values[1]) : top;
const CssLength bottom = values.size() >= 3 ? interpretLength(values[2]) : top;
const CssLength left = values.size() >= 4 ? interpretLength(values[3]) : right;
style.marginTop = top;
style.marginRight = right;
style.marginBottom = bottom;
style.marginLeft = left;
style.marginTop = interpretLength(values[0]);
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
}
} else if (propName == "padding-top") {
@ -411,14 +404,10 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
// Shorthand: 1-4 values for top, right, bottom, left
const auto values = splitWhitespace(propValue);
if (!values.empty()) {
const CssLength top = interpretLength(values[0]);
const CssLength right = values.size() >= 2 ? interpretLength(values[1]) : top;
const CssLength bottom = values.size() >= 3 ? interpretLength(values[2]) : top;
const CssLength left = values.size() >= 4 ? interpretLength(values[3]) : right;
style.paddingTop = top;
style.paddingRight = right;
style.paddingBottom = bottom;
style.paddingLeft = left;
style.paddingTop = interpretLength(values[0]);
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
style.defined.paddingLeft = 1;
}
@ -525,3 +514,184 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
// Inline style parsing (static - doesn't need rule database)
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;
}

View File

@ -76,6 +76,21 @@ class CssParser {
*/
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:
// Storage: maps normalized selector -> style properties
std::unordered_map<std::string, CssStyle> rulesBySelector_;
@ -85,7 +100,7 @@ class CssParser {
static CssStyle parseDeclarations(const std::string& declBlock);
// Individual property value parsers
static TextAlign interpretAlignment(const std::string& val);
static CssTextAlign interpretAlignment(const std::string& val);
static CssFontStyle interpretFontStyle(const std::string& val);
static CssFontWeight interpretFontWeight(const std::string& val);
static CssTextDecoration interpretDecoration(const std::string& val);

View File

@ -2,10 +2,8 @@
#include <cstdint>
// Text alignment options matching CSS text-align property
enum class TextAlign : uint8_t { None = 0, Left = 1, Right = 2, Center = 3, Justify = 4 };
// CSS length unit types
// Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings
enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 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
@ -47,11 +45,11 @@ enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
// Bitmask for tracking which properties have been explicitly set
struct CssPropertyFlags {
uint16_t alignment : 1;
uint16_t textAlign : 1;
uint16_t fontStyle : 1;
uint16_t fontWeight : 1;
uint16_t decoration : 1;
uint16_t indent : 1;
uint16_t textDecoration : 1;
uint16_t textIndent : 1;
uint16_t marginTop : 1;
uint16_t marginBottom : 1;
uint16_t marginLeft : 1;
@ -60,14 +58,13 @@ struct CssPropertyFlags {
uint16_t paddingBottom : 1;
uint16_t paddingLeft : 1;
uint16_t paddingRight : 1;
uint16_t reserved : 3;
CssPropertyFlags()
: alignment(0),
: textAlign(0),
fontStyle(0),
fontWeight(0),
decoration(0),
indent(0),
textDecoration(0),
textIndent(0),
marginTop(0),
marginBottom(0),
marginLeft(0),
@ -75,16 +72,15 @@ struct CssPropertyFlags {
paddingTop(0),
paddingBottom(0),
paddingLeft(0),
paddingRight(0),
reserved(0) {}
paddingRight(0) {}
[[nodiscard]] bool anySet() const {
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || marginLeft ||
marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
}
void clearAll() {
alignment = fontStyle = fontWeight = decoration = indent = 0;
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
}
@ -94,12 +90,12 @@ struct CssPropertyFlags {
// Only stores properties relevant to e-ink text rendering
// Length values are stored as CssLength (value + unit) for deferred resolution
struct CssStyle {
TextAlign alignment = TextAlign::None;
CssTextAlign textAlign = CssTextAlign::Left;
CssFontStyle fontStyle = CssFontStyle::Normal;
CssFontWeight fontWeight = CssFontWeight::Normal;
CssTextDecoration decoration = CssTextDecoration::None;
CssTextDecoration textDecoration = CssTextDecoration::None;
CssLength indent; // First-line indent (deferred resolution)
CssLength textIndent; // First-line indent (deferred resolution)
CssLength marginTop; // Vertical spacing before block
CssLength marginBottom; // Vertical spacing after block
CssLength marginLeft; // Horizontal spacing left of block
@ -114,66 +110,65 @@ struct CssStyle {
// Apply properties from another style, only overwriting if the other style
// has that property explicitly defined
void applyOver(const CssStyle& base) {
if (base.defined.alignment) {
alignment = base.alignment;
defined.alignment = 1;
if (base.hasTextAlign()) {
textAlign = base.textAlign;
defined.textAlign = 1;
}
if (base.defined.fontStyle) {
if (base.hasFontStyle()) {
fontStyle = base.fontStyle;
defined.fontStyle = 1;
}
if (base.defined.fontWeight) {
if (base.hasFontWeight()) {
fontWeight = base.fontWeight;
defined.fontWeight = 1;
}
if (base.defined.decoration) {
decoration = base.decoration;
defined.decoration = 1;
if (base.hasTextDecoration()) {
textDecoration = base.textDecoration;
defined.textDecoration = 1;
}
if (base.defined.indent) {
indent = base.indent;
defined.indent = 1;
if (base.hasTextIndent()) {
textIndent = base.textIndent;
defined.textIndent = 1;
}
if (base.defined.marginTop) {
if (base.hasMarginTop()) {
marginTop = base.marginTop;
defined.marginTop = 1;
}
if (base.defined.marginBottom) {
if (base.hasMarginBottom()) {
marginBottom = base.marginBottom;
defined.marginBottom = 1;
}
if (base.defined.marginLeft) {
if (base.hasMarginLeft()) {
marginLeft = base.marginLeft;
defined.marginLeft = 1;
}
if (base.defined.marginRight) {
if (base.hasMarginRight()) {
marginRight = base.marginRight;
defined.marginRight = 1;
}
if (base.defined.paddingTop) {
if (base.hasPaddingTop()) {
paddingTop = base.paddingTop;
defined.paddingTop = 1;
}
if (base.defined.paddingBottom) {
if (base.hasPaddingBottom()) {
paddingBottom = base.paddingBottom;
defined.paddingBottom = 1;
}
if (base.defined.paddingLeft) {
if (base.hasPaddingLeft()) {
paddingLeft = base.paddingLeft;
defined.paddingLeft = 1;
}
if (base.defined.paddingRight) {
if (base.hasPaddingRight()) {
paddingRight = base.paddingRight;
defined.paddingRight = 1;
}
}
// Compatibility accessors for existing code that uses hasX pattern
[[nodiscard]] bool hasTextAlign() const { return defined.alignment; }
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
[[nodiscard]] bool hasFontStyle() const { return defined.fontStyle; }
[[nodiscard]] bool hasFontWeight() const { return defined.fontWeight; }
[[nodiscard]] bool hasTextDecoration() const { return defined.decoration; }
[[nodiscard]] bool hasTextIndent() const { return defined.indent; }
[[nodiscard]] bool hasTextDecoration() const { return defined.textDecoration; }
[[nodiscard]] bool hasTextIndent() const { return defined.textIndent; }
[[nodiscard]] bool hasMarginTop() const { return defined.marginTop; }
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
@ -183,15 +178,12 @@ struct CssStyle {
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
// Merge another style (alias for applyOver for compatibility)
void merge(const CssStyle& other) { applyOver(other); }
void reset() {
alignment = TextAlign::None;
textAlign = CssTextAlign::Left;
fontStyle = CssFontStyle::Normal;
fontWeight = CssFontWeight::Normal;
decoration = CssTextDecoration::None;
indent = CssLength{};
textDecoration = CssTextDecoration::None;
textIndent = CssLength{};
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
defined.clearAll();

View File

@ -43,39 +43,17 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
return false;
}
// 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
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;
bool isHeaderOrBlock(const char* name) {
return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
}
// Update effective bold/italic/underline based on block style and inline style stack
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
// Start with block-level styles
effectiveBold = currentBlockStyle.hasFontWeight() && currentBlockStyle.fontWeight == CssFontWeight::Bold;
effectiveItalic = currentBlockStyle.hasFontStyle() && currentBlockStyle.fontStyle == CssFontStyle::Italic;
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
effectiveUnderline =
currentBlockStyle.hasTextDecoration() && currentBlockStyle.decoration == CssTextDecoration::Underline;
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
// Apply inline style stack in order
for (const auto& entry : inlineStyleStack) {
@ -98,69 +76,41 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
// Combine style flags using bitwise OR
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (isBold && isItalic) {
fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (isBold) {
fontStyle = EpdFontFamily::BOLD;
} else if (isItalic) {
fontStyle = EpdFontFamily::ITALIC;
if (isBold) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::BOLD);
}
if (isItalic) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
}
if (isUnderline) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
}
// flush the buffer
partWordBuffer[partWordBufferIndex] = '\0';
currentTextBlock->addWord(partWordBuffer, fontStyle, isUnderline);
currentTextBlock->addWord(partWordBuffer, fontStyle);
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
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style, const BlockStyle& blockStyle) {
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it
if (currentTextBlock->isEmpty()) {
currentTextBlock->setStyle(style);
// Merge with existing block style to accumulate margins from parent block elements
// This handles cases like <div margin-bottom:2em><h1>text</h1></div> where the
// div's margin should be preserved even though it has no direct text content
const BlockStyle merged = mergeBlockStyles(currentTextBlock->getBlockStyle(), blockStyle);
currentTextBlock->setBlockStyle(merged);
// Merge with existing block style to accumulate CSS styling from parent block elements.
// This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the
// div's margin should be preserved, even though it has no direct text content.
currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle));
return;
}
makePages();
}
currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing, hyphenationEnabled, blockStyle));
currentTextBlock.reset(new ParsedText(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) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
@ -183,13 +133,17 @@ 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
if (strcmp(name, "table") == 0) {
// Add placeholder text
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->startNewTextBlock(centeredBlockStyle);
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
// Advance depth before processing character data (like you would for a element with text)
// Advance depth before processing character data (like you would for an element with text)
self->depth += 1;
self->characterData(userData, "[Table omitted]", strlen("[Table omitted]"));
@ -214,9 +168,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->startNewTextBlock(centeredBlockStyle);
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
// Advance depth before processing character data (like you would for a element with text)
// Advance depth before processing character data (like you would for an element with text)
self->depth += 1;
self->characterData(userData, alt.c_str(), alt.length());
@ -244,9 +198,6 @@ 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
CssStyle cssStyle;
if (self->cssParser) {
@ -255,34 +206,16 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// Merge inline style (highest priority)
if (!styleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
cssStyle.merge(inlineStyle);
cssStyle.applyOver(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)) {
// Headers: center aligned, bold, apply CSS overrides
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->currentCssStyle = cssStyle;
self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment));
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
self->updateEffectiveInlineStyle();
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
@ -291,31 +224,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
self->flushPartWordBuffer();
}
self->startNewTextBlock(self->currentTextBlock->getStyle());
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
} else {
// Determine alignment from CSS or default
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->currentCssStyle = cssStyle;
self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment));
self->updateEffectiveInlineStyle();
if (strcmp(name, "li") == 0) {
@ -352,7 +264,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.decoration == CssTextDecoration::Underline;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@ -369,11 +281,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.decoration == CssTextDecoration::Underline;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
} else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) {
// Handle span and other inline elements for CSS styling
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
StyleStackEntry entry;
@ -388,7 +300,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.decoration == CssTextDecoration::Underline;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@ -464,12 +376,12 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
const bool headerOrBlockTag = isHeaderOrBlock(name);
// Flush buffer with current style BEFORE any style changes
if (self->partWordBufferIndex > 0) {
// Flush if style will change OR if we're closing a block/structural element
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) ||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 ||
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
@ -508,15 +420,18 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->updateEffectiveInlineStyle();
}
// Clear block style when leaving block elements
if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
self->currentBlockStyle.reset();
// Clear block style when leaving header or block elements
if (headerOrBlockTag) {
self->currentCssStyle.reset();
self->updateEffectiveInlineStyle();
}
}
bool ChapterHtmlSlimParser::parseAndBuildPages() {
startNewTextBlock((TextBlock::Style)this->paragraphAlignment);
auto paragraphAlignmentBlockStyle = BlockStyle();
paragraphAlignmentBlockStyle.textAlignDefined = true;
paragraphAlignmentBlockStyle.alignment = static_cast<CssTextAlign>(this->paragraphAlignment);
startNewTextBlock(paragraphAlignmentBlockStyle);
const XML_Parser parser = XML_ParserCreate(nullptr);
int done;
@ -624,11 +539,14 @@ void ChapterHtmlSlimParser::makePages() {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
// Apply marginTop before the paragraph (stored in pixels)
// Apply top spacing before the paragraph (stored in pixels)
const BlockStyle& blockStyle = currentTextBlock->getBlockStyle();
if (blockStyle.marginTop > 0) {
currentPageNextY += blockStyle.marginTop;
}
if (blockStyle.paddingTop > 0) {
currentPageNextY += blockStyle.paddingTop;
}
// Calculate effective width accounting for horizontal margins/padding
const int horizontalInset = blockStyle.totalHorizontalInset();
@ -639,10 +557,13 @@ void ChapterHtmlSlimParser::makePages() {
renderer, fontId, effectiveWidth,
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
// Apply marginBottom after the paragraph (stored in pixels)
// Apply bottom spacing after the paragraph (stored in pixels)
if (blockStyle.marginBottom > 0) {
currentPageNextY += blockStyle.marginBottom;
}
if (blockStyle.paddingBottom > 0) {
currentPageNextY += blockStyle.paddingBottom;
}
// Extra paragraph spacing if enabled (default behavior)
if (extraParagraphSpacing) {

View File

@ -50,14 +50,13 @@ class ChapterHtmlSlimParser {
bool hasUnderline = false, underline = false;
};
std::vector<StyleStackEntry> inlineStyleStack;
CssStyle currentBlockStyle;
CssStyle currentCssStyle;
bool effectiveBold = false;
bool effectiveItalic = false;
bool effectiveUnderline = false;
void updateEffectiveInlineStyle();
void startNewTextBlock(TextBlock::Style style, const BlockStyle& blockStyle);
void startNewTextBlock(TextBlock::Style style);
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer();
void makePages();
// XML callbacks

View File

@ -470,7 +470,7 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
return fontMap.at(fontId).getGlyph(' ', EpdFontFamily::REGULAR)->advanceX;
}
int GfxRenderer::getIndentWidth(const int fontId, const char* text) const {
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return 0;

View File

@ -78,7 +78,7 @@ class GfxRenderer {
void drawText(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getSpaceWidth(int fontId) const;
int getIndentWidth(int fontId, const char* text) const;
int getTextAdvanceX(int fontId, const char* text) const;
int getFontAscenderSize(int fontId) const;
int getLineHeight(int fontId) const;
std::string truncatedText(int fontId, const char* text, int maxWidth,

View File

@ -238,7 +238,8 @@ void SleepActivity::renderCoverSleepScreen() const {
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
// Handle EPUB file
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastEpub.load()) {
// Skip loading css since we only need metadata here
if (!lastEpub.load(true, true)) {
Serial.println("[SLP] Failed to load last epub");
return renderDefaultSleepScreen();
}

View File

@ -52,7 +52,8 @@ void HomeActivity::onEnter() {
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
// Skip loading css since we only need metadata here
epub.load(false, true);
if (!epub.getTitle().empty()) {
lastBookTitle = std::string(epub.getTitle());
}

View File

@ -520,7 +520,7 @@ void WifiSelectionActivity::renderNetworkList() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height) / 2;
renderer.drawCenteredText(UI_10_FONT_ID, top, "No networks found");
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again");
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press Connect to scan again");
} else {
// Calculate how many networks we can display
constexpr int startY = 60;
@ -546,8 +546,8 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw network name (truncate if too long)
std::string displayName = network.ssid;
if (displayName.length() > 16) {
displayName.replace(13, displayName.length() - 13, "...");
if (displayName.length() > 33) {
displayName.replace(30, displayName.length() - 30, "...");
}
renderer.drawText(UI_10_FONT_ID, 20, networkY, displayName.c_str());