A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Add CSS parsing and CSS support in EPUBs (#411)

## Summary

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

- Adds basic CSS parsing to EPUBs and determine the CSS rules when
rendering to the screen so that text is styled correctly. Currently
supports bold, underline, italics, margin, padding, and text alignment

## Additional Context

- My main reason for wanting this is that the book I'm currently
reading, Carl's Doomsday Scenario (2nd in the Dungeon Crawler Carl
series), relies _a lot_ on styled text for telling parts of the story.
When text is bolded, it's supposed to be a message that's rendered
"on-screen" in the story. When characters are "chatting" with each
other, the text is bolded and their names are underlined. Plus, normal
emphasis is provided with italicizing words here and there. So, this
greatly improves my experience reading this book on the Xteink, and I
figured it was useful enough for others too.
- For transparency: I'm a software engineer, but I'm mostly frontend and
TypeScript/JavaScript. It's been _years_ since I did any C/C++, so I
would not be surprised if I'm doing something dumb along the way in this
code. Please don't hesitate to ask for changes if something looks off. I
heavily relied on Claude Code for help, and I had a lot of inspiration
from how [microreader](https://github.com/CidVonHighwind/microreader)
achieves their CSS parsing and styling. I did give this as good of a
code review as I could and went through everything, and _it works on my
machine_ 😄

### Before

![IMG_6271](https://github.com/user-attachments/assets/dba7554d-efb6-4d13-88bc-8b83cd1fc615)

![IMG_6272](https://github.com/user-attachments/assets/61ba2de0-87c9-4f39-956f-013da4fe20a4)

### After

![IMG_6268](https://github.com/user-attachments/assets/ebe11796-cca9-4a46-b9c7-0709c7932818)

![IMG_6269](https://github.com/user-attachments/assets/e89c33dc-ff47-4bb7-855e-863fe44b3202)

---

### AI Usage

Did you use AI tools to help write this code? **YES**, Claude Code

authored by

Jake Kenneally and committed by
GitHub
2cf799f4 db659f3e

+1622 -125
+10 -14
lib/EpdFont/EpdFontFamily.cpp
··· 1 1 #include "EpdFontFamily.h" 2 2 3 3 const EpdFont* EpdFontFamily::getFont(const Style style) const { 4 - if (style == BOLD && bold) { 4 + // Extract font style bits (ignore UNDERLINE bit for font selection) 5 + const bool hasBold = (style & BOLD) != 0; 6 + const bool hasItalic = (style & ITALIC) != 0; 7 + 8 + if (hasBold && hasItalic) { 9 + if (boldItalic) return boldItalic; 10 + if (bold) return bold; 11 + if (italic) return italic; 12 + } else if (hasBold && bold) { 5 13 return bold; 6 - } 7 - if (style == ITALIC && italic) { 14 + } else if (hasItalic && italic) { 8 15 return italic; 9 - } 10 - if (style == BOLD_ITALIC) { 11 - if (boldItalic) { 12 - return boldItalic; 13 - } 14 - if (bold) { 15 - return bold; 16 - } 17 - if (italic) { 18 - return italic; 19 - } 20 16 } 21 17 22 18 return regular;
+1 -1
lib/EpdFont/EpdFontFamily.h
··· 3 3 4 4 class EpdFontFamily { 5 5 public: 6 - enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3 }; 6 + enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4 }; 7 7 8 8 explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr, 9 9 const EpdFont* boldItalic = nullptr)
+86 -1
lib/Epub/Epub.cpp
··· 86 86 tocNavItem = opfParser.tocNavPath; 87 87 } 88 88 89 + if (!opfParser.cssFiles.empty()) { 90 + cssFiles = opfParser.cssFiles; 91 + } 92 + 89 93 Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); 90 94 return true; 91 95 } ··· 204 208 return true; 205 209 } 206 210 211 + std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; } 212 + 213 + bool Epub::loadCssRulesFromCache() const { 214 + FsFile cssCacheFile; 215 + if (SdMan.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) { 216 + if (cssParser->loadFromCache(cssCacheFile)) { 217 + cssCacheFile.close(); 218 + Serial.printf("[%lu] [EBP] Loaded CSS rules from cache\n", millis()); 219 + return true; 220 + } 221 + cssCacheFile.close(); 222 + Serial.printf("[%lu] [EBP] CSS cache invalid, reparsing\n", millis()); 223 + } 224 + return false; 225 + } 226 + 227 + void Epub::parseCssFiles() const { 228 + if (cssFiles.empty()) { 229 + Serial.printf("[%lu] [EBP] No CSS files to parse, but CssParser created for inline styles\n", millis()); 230 + } 231 + 232 + // Try to load from CSS cache first 233 + if (!loadCssRulesFromCache()) { 234 + // Cache miss - parse CSS files 235 + for (const auto& cssPath : cssFiles) { 236 + Serial.printf("[%lu] [EBP] Parsing CSS file: %s\n", millis(), cssPath.c_str()); 237 + 238 + // Extract CSS file to temp location 239 + const auto tmpCssPath = getCachePath() + "/.tmp.css"; 240 + FsFile tempCssFile; 241 + if (!SdMan.openFileForWrite("EBP", tmpCssPath, tempCssFile)) { 242 + Serial.printf("[%lu] [EBP] Could not create temp CSS file\n", millis()); 243 + continue; 244 + } 245 + if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) { 246 + Serial.printf("[%lu] [EBP] Could not read CSS file: %s\n", millis(), cssPath.c_str()); 247 + tempCssFile.close(); 248 + SdMan.remove(tmpCssPath.c_str()); 249 + continue; 250 + } 251 + tempCssFile.close(); 252 + 253 + // Parse the CSS file 254 + if (!SdMan.openFileForRead("EBP", tmpCssPath, tempCssFile)) { 255 + Serial.printf("[%lu] [EBP] Could not open temp CSS file for reading\n", millis()); 256 + SdMan.remove(tmpCssPath.c_str()); 257 + continue; 258 + } 259 + cssParser->loadFromStream(tempCssFile); 260 + tempCssFile.close(); 261 + SdMan.remove(tmpCssPath.c_str()); 262 + } 263 + 264 + // Save to cache for next time 265 + FsFile cssCacheFile; 266 + if (SdMan.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) { 267 + cssParser->saveToCache(cssCacheFile); 268 + cssCacheFile.close(); 269 + } 270 + 271 + Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files\n", millis(), cssParser->ruleCount(), 272 + cssFiles.size()); 273 + } 274 + } 275 + 207 276 // load in the meta data for the epub file 208 - bool Epub::load(const bool buildIfMissing) { 277 + bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { 209 278 Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); 210 279 211 280 // Initialize spine/TOC cache 212 281 bookMetadataCache.reset(new BookMetadataCache(cachePath)); 282 + // Always create CssParser - needed for inline style parsing even without CSS files 283 + cssParser.reset(new CssParser()); 213 284 214 285 // Try to load existing cache first 215 286 if (bookMetadataCache->load()) { 287 + if (!skipLoadingCss && !loadCssRulesFromCache()) { 288 + Serial.printf("[%lu] [EBP] Warning: CSS rules cache not found, attempting to parse CSS files\n", millis()); 289 + // to get CSS file list 290 + if (!parseContentOpf(bookMetadataCache->coreMetadata)) { 291 + Serial.printf("[%lu] [EBP] Could not parse content.opf from cached bookMetadata for CSS files\n", millis()); 292 + // continue anyway - book will work without CSS and we'll still load any inline style CSS 293 + } 294 + parseCssFiles(); 295 + } 216 296 Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); 217 297 return true; 218 298 } ··· 307 387 if (!bookMetadataCache->load()) { 308 388 Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis()); 309 389 return false; 390 + } 391 + 392 + if (!skipLoadingCss) { 393 + // Parse CSS files after cache reload 394 + parseCssFiles(); 310 395 } 311 396 312 397 Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
+10 -1
lib/Epub/Epub.h
··· 8 8 #include <vector> 9 9 10 10 #include "Epub/BookMetadataCache.h" 11 + #include "Epub/css/CssParser.h" 11 12 12 13 class ZipFile; 13 14 ··· 24 25 std::string cachePath; 25 26 // Spine and TOC cache 26 27 std::unique_ptr<BookMetadataCache> bookMetadataCache; 28 + // CSS parser for styling 29 + std::unique_ptr<CssParser> cssParser; 30 + // CSS files 31 + std::vector<std::string> cssFiles; 27 32 28 33 bool findContentOpfFile(std::string* contentOpfFile) const; 29 34 bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata); 30 35 bool parseTocNcxFile() const; 31 36 bool parseTocNavFile() const; 37 + void parseCssFiles() const; 38 + std::string getCssRulesCache() const; 39 + bool loadCssRulesFromCache() const; 32 40 33 41 public: 34 42 explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { ··· 37 45 } 38 46 ~Epub() = default; 39 47 std::string& getBasePath() { return contentBasePath; } 40 - bool load(bool buildIfMissing = true); 48 + bool load(bool buildIfMissing = true, bool skipLoadingCss = false); 41 49 bool clearCache() const; 42 50 void setupCacheDir() const; 43 51 const std::string& getCachePath() const; ··· 64 72 65 73 size_t getBookSize() const; 66 74 float calculateProgress(int currentSpineIndex, float currentSpineRead) const; 75 + const CssParser* getCssParser() const { return cssParser.get(); } 67 76 };
+60 -18
lib/Epub/Epub/ParsedText.cpp
··· 49 49 50 50 } // namespace 51 51 52 - void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle) { 52 + void ParsedText::addWord(std::string word, const EpdFontFamily::Style style, const bool underline) { 53 53 if (word.empty()) return; 54 54 55 55 words.push_back(std::move(word)); 56 - wordStyles.push_back(fontStyle); 56 + EpdFontFamily::Style combinedStyle = style; 57 + if (underline) { 58 + combinedStyle = static_cast<EpdFontFamily::Style>(combinedStyle | EpdFontFamily::UNDERLINE); 59 + } 60 + wordStyles.push_back(combinedStyle); 57 61 } 58 62 59 63 // Consumes data to minimize memory usage ··· 109 113 return {}; 110 114 } 111 115 116 + // Calculate first line indent (only for left/justified text without extra paragraph spacing) 117 + const int firstLineIndent = 118 + blockStyle.textIndent > 0 && !extraParagraphSpacing && 119 + (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) 120 + ? blockStyle.textIndent 121 + : 0; 122 + 112 123 // Ensure any word that would overflow even as the first entry on a line is split using fallback hyphenation. 113 124 for (size_t i = 0; i < wordWidths.size(); ++i) { 114 - while (wordWidths[i] > pageWidth) { 115 - if (!hyphenateWordAtIndex(i, pageWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) { 125 + // First word needs to fit in reduced width if there's an indent 126 + const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth; 127 + while (wordWidths[i] > effectiveWidth) { 128 + if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) { 116 129 break; 117 130 } 118 131 } ··· 133 146 int currlen = -spaceWidth; 134 147 dp[i] = MAX_COST; 135 148 149 + // First line has reduced width due to text-indent 150 + const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth; 151 + 136 152 for (size_t j = i; j < totalWordCount; ++j) { 137 153 // Current line length: previous width + space + current word width 138 154 currlen += wordWidths[j] + spaceWidth; 139 155 140 - if (currlen > pageWidth) { 156 + if (currlen > effectivePageWidth) { 141 157 break; 142 158 } 143 159 ··· 145 161 if (j == totalWordCount - 1) { 146 162 cost = 0; // Last line 147 163 } else { 148 - const int remainingSpace = pageWidth - currlen; 164 + const int remainingSpace = effectivePageWidth - currlen; 149 165 // Use long long for the square to prevent overflow 150 166 const long long cost_ll = static_cast<long long>(remainingSpace) * remainingSpace + dp[j + 1]; 151 167 ··· 200 216 return; 201 217 } 202 218 203 - if (style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) { 219 + if (blockStyle.textIndentDefined) { 220 + // CSS text-indent is explicitly set (even if 0) - don't use fallback EmSpace 221 + // The actual indent positioning is handled in extractLine() 222 + } else if (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) { 223 + // No CSS text-indent defined - use EmSpace fallback for visual indent 204 224 words.front().insert(0, "\xe2\x80\x83"); 205 225 } 206 226 } ··· 209 229 std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, 210 230 const int pageWidth, const int spaceWidth, 211 231 std::vector<uint16_t>& wordWidths) { 232 + // Calculate first line indent (only for left/justified text without extra paragraph spacing) 233 + const int firstLineIndent = 234 + blockStyle.textIndent > 0 && !extraParagraphSpacing && 235 + (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) 236 + ? blockStyle.textIndent 237 + : 0; 238 + 212 239 std::vector<size_t> lineBreakIndices; 213 240 size_t currentIndex = 0; 241 + bool isFirstLine = true; 214 242 215 243 while (currentIndex < wordWidths.size()) { 216 244 const size_t lineStart = currentIndex; 217 245 int lineWidth = 0; 246 + 247 + // First line has reduced width due to text-indent 248 + const int effectivePageWidth = isFirstLine ? pageWidth - firstLineIndent : pageWidth; 218 249 219 250 // Consume as many words as possible for current line, splitting when prefixes fit 220 251 while (currentIndex < wordWidths.size()) { ··· 223 254 const int candidateWidth = spacing + wordWidths[currentIndex]; 224 255 225 256 // Word fits on current line 226 - if (lineWidth + candidateWidth <= pageWidth) { 257 + if (lineWidth + candidateWidth <= effectivePageWidth) { 227 258 lineWidth += candidateWidth; 228 259 ++currentIndex; 229 260 continue; 230 261 } 231 262 232 263 // Word would overflow — try to split based on hyphenation points 233 - const int availableWidth = pageWidth - lineWidth - spacing; 264 + const int availableWidth = effectivePageWidth - lineWidth - spacing; 234 265 const bool allowFallbackBreaks = isFirstWord; // Only for first word on line 235 266 236 267 if (availableWidth > 0 && ··· 250 281 } 251 282 252 283 lineBreakIndices.push_back(currentIndex); 284 + isFirstLine = false; 253 285 } 254 286 255 287 return lineBreakIndices; ··· 334 366 const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; 335 367 const size_t lineWordCount = lineBreak - lastBreakAt; 336 368 369 + // Calculate first line indent (only for left/justified text without extra paragraph spacing) 370 + const bool isFirstLine = breakIndex == 0; 371 + const int firstLineIndent = 372 + isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing && 373 + (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) 374 + ? blockStyle.textIndent 375 + : 0; 376 + 337 377 // Calculate total word width for this line 338 378 int lineWordWidthSum = 0; 339 379 for (size_t i = lastBreakAt; i < lineBreak; i++) { 340 380 lineWordWidthSum += wordWidths[i]; 341 381 } 342 382 343 - // Calculate spacing 344 - const int spareSpace = pageWidth - lineWordWidthSum; 383 + // Calculate spacing (account for indent reducing effective page width on first line) 384 + const int effectivePageWidth = pageWidth - firstLineIndent; 385 + const int spareSpace = effectivePageWidth - lineWordWidthSum; 345 386 346 387 int spacing = spaceWidth; 347 388 const bool isLastLine = breakIndex == lineBreakIndices.size() - 1; 348 389 349 - if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { 390 + if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && lineWordCount >= 2) { 350 391 spacing = spareSpace / (lineWordCount - 1); 351 392 } 352 393 353 - // Calculate initial x position 354 - uint16_t xpos = 0; 355 - if (style == TextBlock::RIGHT_ALIGN) { 394 + // Calculate initial x position (first line starts at indent for left/justified text) 395 + auto xpos = static_cast<uint16_t>(firstLineIndent); 396 + if (blockStyle.alignment == CssTextAlign::Right) { 356 397 xpos = spareSpace - (lineWordCount - 1) * spaceWidth; 357 - } else if (style == TextBlock::CENTER_ALIGN) { 398 + } else if (blockStyle.alignment == CssTextAlign::Center) { 358 399 xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2; 359 400 } 360 401 ··· 384 425 } 385 426 } 386 427 387 - processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); 388 - } 428 + processLine( 429 + std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle)); 430 + }
+8 -7
lib/Epub/Epub/ParsedText.h
··· 8 8 #include <string> 9 9 #include <vector> 10 10 11 + #include "blocks/BlockStyle.h" 11 12 #include "blocks/TextBlock.h" 12 13 13 14 class GfxRenderer; ··· 15 16 class ParsedText { 16 17 std::list<std::string> words; 17 18 std::list<EpdFontFamily::Style> wordStyles; 18 - TextBlock::Style style; 19 + BlockStyle blockStyle; 19 20 bool extraParagraphSpacing; 20 21 bool hyphenationEnabled; 21 22 ··· 32 33 std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId); 33 34 34 35 public: 35 - explicit ParsedText(const TextBlock::Style style, const bool extraParagraphSpacing, 36 - const bool hyphenationEnabled = false) 37 - : style(style), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {} 36 + explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false, 37 + const BlockStyle& blockStyle = BlockStyle()) 38 + : blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {} 38 39 ~ParsedText() = default; 39 40 40 - void addWord(std::string word, EpdFontFamily::Style fontStyle); 41 - void setStyle(const TextBlock::Style style) { this->style = style; } 42 - TextBlock::Style getStyle() const { return style; } 41 + void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false); 42 + void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } 43 + BlockStyle& getBlockStyle() { return blockStyle; } 43 44 size_t size() const { return words.size(); } 44 45 bool isEmpty() const { return words.empty(); } 45 46 void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
+3 -2
lib/Epub/Epub/Section.cpp
··· 8 8 #include "parsers/ChapterHtmlSlimParser.h" 9 9 10 10 namespace { 11 - constexpr uint8_t SECTION_FILE_VERSION = 10; 11 + constexpr uint8_t SECTION_FILE_VERSION = 11; 12 12 constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + 13 13 sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + 14 14 sizeof(uint32_t); ··· 179 179 ChapterHtmlSlimParser visitor( 180 180 tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, 181 181 viewportHeight, hyphenationEnabled, 182 - [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn); 182 + [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn, 183 + epub->getCssParser()); 183 184 Hyphenator::setPreferredLanguage(epub->getLanguage()); 184 185 success = visitor.parseAndBuildPages(); 185 186
+90
lib/Epub/Epub/blocks/BlockStyle.h
··· 1 + #pragma once 2 + 3 + #include <cstdint> 4 + 5 + #include "Epub/css/CssStyle.h" 6 + 7 + /** 8 + * BlockStyle - Block-level styling properties 9 + */ 10 + struct BlockStyle { 11 + CssTextAlign alignment = CssTextAlign::Justify; 12 + 13 + // Spacing (in pixels) 14 + int16_t marginTop = 0; 15 + int16_t marginBottom = 0; 16 + int16_t marginLeft = 0; 17 + int16_t marginRight = 0; 18 + int16_t paddingTop = 0; // treated same as margin for rendering 19 + int16_t paddingBottom = 0; // treated same as margin for rendering 20 + int16_t paddingLeft = 0; // treated same as margin for rendering 21 + int16_t paddingRight = 0; // treated same as margin for rendering 22 + int16_t textIndent = 0; 23 + bool textIndentDefined = false; // true if text-indent was explicitly set in CSS 24 + bool textAlignDefined = false; // true if text-align was explicitly set in CSS 25 + 26 + // Combined horizontal insets (margin + padding) 27 + [[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; } 28 + [[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; } 29 + [[nodiscard]] int16_t totalHorizontalInset() const { return leftInset() + rightInset(); } 30 + 31 + // Combine with another block style. Useful for parent -> child styles, where the child style should be 32 + // applied on top of the parent's style to get the combined style. 33 + BlockStyle getCombinedBlockStyle(const BlockStyle& child) const { 34 + BlockStyle combinedBlockStyle; 35 + 36 + combinedBlockStyle.marginTop = static_cast<int16_t>(child.marginTop + marginTop); 37 + combinedBlockStyle.marginBottom = static_cast<int16_t>(child.marginBottom + marginBottom); 38 + combinedBlockStyle.marginLeft = static_cast<int16_t>(child.marginLeft + marginLeft); 39 + combinedBlockStyle.marginRight = static_cast<int16_t>(child.marginRight + marginRight); 40 + 41 + combinedBlockStyle.paddingTop = static_cast<int16_t>(child.paddingTop + paddingTop); 42 + combinedBlockStyle.paddingBottom = static_cast<int16_t>(child.paddingBottom + paddingBottom); 43 + combinedBlockStyle.paddingLeft = static_cast<int16_t>(child.paddingLeft + paddingLeft); 44 + combinedBlockStyle.paddingRight = static_cast<int16_t>(child.paddingRight + paddingRight); 45 + // Text indent: use child's if defined 46 + if (child.textIndentDefined) { 47 + combinedBlockStyle.textIndent = child.textIndent; 48 + combinedBlockStyle.textIndentDefined = true; 49 + } else { 50 + combinedBlockStyle.textIndent = textIndent; 51 + combinedBlockStyle.textIndentDefined = textIndentDefined; 52 + } 53 + // Text align: use child's if defined 54 + if (child.textAlignDefined) { 55 + combinedBlockStyle.alignment = child.alignment; 56 + combinedBlockStyle.textAlignDefined = true; 57 + } else { 58 + combinedBlockStyle.alignment = alignment; 59 + combinedBlockStyle.textAlignDefined = textAlignDefined; 60 + } 61 + return combinedBlockStyle; 62 + } 63 + 64 + // Create a BlockStyle from CSS style properties, resolving CssLength values to pixels 65 + // emSize is the current font line height, used for em/rem unit conversion 66 + // paragraphAlignment is the user's paragraphAlignment setting preference 67 + static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment) { 68 + BlockStyle blockStyle; 69 + // Resolve all CssLength values to pixels using the current font's em size 70 + blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize); 71 + blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize); 72 + blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize); 73 + blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize); 74 + 75 + blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize); 76 + blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize); 77 + blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize); 78 + blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize); 79 + 80 + blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize); 81 + blockStyle.textIndentDefined = cssStyle.hasTextIndent(); 82 + blockStyle.textAlignDefined = cssStyle.hasTextAlign(); 83 + if (blockStyle.textAlignDefined) { 84 + blockStyle.alignment = cssStyle.textAlign; 85 + } else { 86 + blockStyle.alignment = paragraphAlignment; 87 + } 88 + return blockStyle; 89 + } 90 + };
+54 -8
lib/Epub/Epub/blocks/TextBlock.cpp
··· 14 14 auto wordIt = words.begin(); 15 15 auto wordStylesIt = wordStyles.begin(); 16 16 auto wordXposIt = wordXpos.begin(); 17 + for (size_t i = 0; i < words.size(); i++) { 18 + const int wordX = *wordXposIt + x; 19 + const EpdFontFamily::Style currentStyle = *wordStylesIt; 20 + renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle); 17 21 18 - for (size_t i = 0; i < words.size(); i++) { 19 - renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt); 22 + if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) { 23 + const std::string& w = *wordIt; 24 + const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle); 25 + // y is the top of the text line; add ascender to reach baseline, then offset 2px below 26 + const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2; 27 + 28 + int startX = wordX; 29 + int underlineWidth = fullWordWidth; 30 + 31 + // if word starts with em-space ("\xe2\x80\x83"), account for the additional indent before drawing the line 32 + if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 && 33 + static_cast<uint8_t>(w[2]) == 0x83) { 34 + const char* visiblePtr = w.c_str() + 3; 35 + const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str()); 36 + const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle); 37 + startX = wordX + prefixWidth; 38 + underlineWidth = visibleWidth; 39 + } 40 + 41 + renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true); 42 + } 20 43 21 44 std::advance(wordIt, 1); 22 45 std::advance(wordStylesIt, 1); ··· 37 60 for (auto x : wordXpos) serialization::writePod(file, x); 38 61 for (auto s : wordStyles) serialization::writePod(file, s); 39 62 40 - // Block style 41 - serialization::writePod(file, style); 63 + // Style (alignment + margins/padding/indent) 64 + serialization::writePod(file, blockStyle.alignment); 65 + serialization::writePod(file, blockStyle.textAlignDefined); 66 + serialization::writePod(file, blockStyle.marginTop); 67 + serialization::writePod(file, blockStyle.marginBottom); 68 + serialization::writePod(file, blockStyle.marginLeft); 69 + serialization::writePod(file, blockStyle.marginRight); 70 + serialization::writePod(file, blockStyle.paddingTop); 71 + serialization::writePod(file, blockStyle.paddingBottom); 72 + serialization::writePod(file, blockStyle.paddingLeft); 73 + serialization::writePod(file, blockStyle.paddingRight); 74 + serialization::writePod(file, blockStyle.textIndent); 75 + serialization::writePod(file, blockStyle.textIndentDefined); 42 76 43 77 return true; 44 78 } ··· 48 82 std::list<std::string> words; 49 83 std::list<uint16_t> wordXpos; 50 84 std::list<EpdFontFamily::Style> wordStyles; 51 - Style style; 85 + BlockStyle blockStyle; 52 86 53 87 // Word count 54 88 serialization::readPod(file, wc); ··· 67 101 for (auto& x : wordXpos) serialization::readPod(file, x); 68 102 for (auto& s : wordStyles) serialization::readPod(file, s); 69 103 70 - // Block style 71 - serialization::readPod(file, style); 104 + // Style (alignment + margins/padding/indent) 105 + serialization::readPod(file, blockStyle.alignment); 106 + serialization::readPod(file, blockStyle.textAlignDefined); 107 + serialization::readPod(file, blockStyle.marginTop); 108 + serialization::readPod(file, blockStyle.marginBottom); 109 + serialization::readPod(file, blockStyle.marginLeft); 110 + serialization::readPod(file, blockStyle.marginRight); 111 + serialization::readPod(file, blockStyle.paddingTop); 112 + serialization::readPod(file, blockStyle.paddingBottom); 113 + serialization::readPod(file, blockStyle.paddingLeft); 114 + serialization::readPod(file, blockStyle.paddingRight); 115 + serialization::readPod(file, blockStyle.textIndent); 116 + serialization::readPod(file, blockStyle.textIndentDefined); 72 117 73 - return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style)); 118 + return std::unique_ptr<TextBlock>( 119 + new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), blockStyle)); 74 120 }
+9 -13
lib/Epub/Epub/blocks/TextBlock.h
··· 7 7 #include <string> 8 8 9 9 #include "Block.h" 10 + #include "BlockStyle.h" 10 11 11 12 // Represents a line of text on a page 12 13 class TextBlock final : public Block { 13 - public: 14 - enum Style : uint8_t { 15 - JUSTIFIED = 0, 16 - LEFT_ALIGN = 1, 17 - CENTER_ALIGN = 2, 18 - RIGHT_ALIGN = 3, 19 - }; 20 - 21 14 private: 22 15 std::list<std::string> words; 23 16 std::list<uint16_t> wordXpos; 24 17 std::list<EpdFontFamily::Style> wordStyles; 25 - Style style; 18 + BlockStyle blockStyle; 26 19 27 20 public: 28 21 explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos, 29 - std::list<EpdFontFamily::Style> word_styles, const Style style) 30 - : words(std::move(words)), wordXpos(std::move(word_xpos)), wordStyles(std::move(word_styles)), style(style) {} 22 + std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle()) 23 + : words(std::move(words)), 24 + wordXpos(std::move(word_xpos)), 25 + wordStyles(std::move(word_styles)), 26 + blockStyle(blockStyle) {} 31 27 ~TextBlock() override = default; 32 - void setStyle(const Style style) { this->style = style; } 33 - Style getStyle() const { return style; } 28 + void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } 29 + const BlockStyle& getBlockStyle() const { return blockStyle; } 34 30 bool isEmpty() override { return words.empty(); } 35 31 void layout(GfxRenderer& renderer) override {}; 36 32 // given a renderer works out where to break the words into lines
+697
lib/Epub/Epub/css/CssParser.cpp
··· 1 + #include "CssParser.h" 2 + 3 + #include <HardwareSerial.h> 4 + 5 + #include <algorithm> 6 + #include <cctype> 7 + 8 + namespace { 9 + 10 + // Buffer size for reading CSS files 11 + constexpr size_t READ_BUFFER_SIZE = 512; 12 + 13 + // Maximum CSS file size we'll process (prevent memory issues) 14 + constexpr size_t MAX_CSS_SIZE = 64 * 1024; 15 + 16 + // Check if character is CSS whitespace 17 + bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; } 18 + 19 + // Read entire file into string (with size limit) 20 + std::string readFileContent(FsFile& file) { 21 + std::string content; 22 + content.reserve(std::min(static_cast<size_t>(file.size()), MAX_CSS_SIZE)); 23 + 24 + char buffer[READ_BUFFER_SIZE]; 25 + while (file.available() && content.size() < MAX_CSS_SIZE) { 26 + const int bytesRead = file.read(buffer, sizeof(buffer)); 27 + if (bytesRead <= 0) break; 28 + content.append(buffer, bytesRead); 29 + } 30 + return content; 31 + } 32 + 33 + // Remove CSS comments (/* ... */) from content 34 + std::string stripComments(const std::string& css) { 35 + std::string result; 36 + result.reserve(css.size()); 37 + 38 + size_t pos = 0; 39 + while (pos < css.size()) { 40 + // Look for start of comment 41 + if (pos + 1 < css.size() && css[pos] == '/' && css[pos + 1] == '*') { 42 + // Find end of comment 43 + const size_t endPos = css.find("*/", pos + 2); 44 + if (endPos == std::string::npos) { 45 + // Unterminated comment - skip rest of file 46 + break; 47 + } 48 + pos = endPos + 2; 49 + } else { 50 + result.push_back(css[pos]); 51 + ++pos; 52 + } 53 + } 54 + return result; 55 + } 56 + 57 + // Skip @-rules (like @media, @import, @font-face) 58 + // Returns position after the @-rule 59 + size_t skipAtRule(const std::string& css, const size_t start) { 60 + // Find the end - either semicolon (simple @-rule) or matching brace 61 + size_t pos = start + 1; // Skip the '@' 62 + 63 + // Skip identifier 64 + while (pos < css.size() && (std::isalnum(css[pos]) || css[pos] == '-')) { 65 + ++pos; 66 + } 67 + 68 + // Look for { or ; 69 + int braceDepth = 0; 70 + while (pos < css.size()) { 71 + const char c = css[pos]; 72 + if (c == '{') { 73 + ++braceDepth; 74 + } else if (c == '}') { 75 + --braceDepth; 76 + if (braceDepth == 0) { 77 + return pos + 1; 78 + } 79 + } else if (c == ';' && braceDepth == 0) { 80 + return pos + 1; 81 + } 82 + ++pos; 83 + } 84 + return css.size(); 85 + } 86 + 87 + // Extract next rule from CSS content 88 + // Returns true if a rule was found, with selector and body filled 89 + bool extractNextRule(const std::string& css, size_t& pos, std::string& selector, std::string& body) { 90 + selector.clear(); 91 + body.clear(); 92 + 93 + // Skip whitespace and @-rules until we find a regular rule 94 + while (pos < css.size()) { 95 + // Skip whitespace 96 + while (pos < css.size() && isCssWhitespace(css[pos])) { 97 + ++pos; 98 + } 99 + 100 + if (pos >= css.size()) return false; 101 + 102 + // Handle @-rules iteratively (avoids recursion/stack overflow) 103 + if (css[pos] == '@') { 104 + pos = skipAtRule(css, pos); 105 + continue; // Try again after skipping the @-rule 106 + } 107 + 108 + break; // Found start of a regular rule 109 + } 110 + 111 + if (pos >= css.size()) return false; 112 + 113 + // Find opening brace 114 + const size_t bracePos = css.find('{', pos); 115 + if (bracePos == std::string::npos) return false; 116 + 117 + // Extract selector (everything before the brace) 118 + selector = css.substr(pos, bracePos - pos); 119 + 120 + // Find matching closing brace 121 + int depth = 1; 122 + const size_t bodyStart = bracePos + 1; 123 + size_t bodyEnd = bodyStart; 124 + 125 + while (bodyEnd < css.size() && depth > 0) { 126 + if (css[bodyEnd] == '{') 127 + ++depth; 128 + else if (css[bodyEnd] == '}') 129 + --depth; 130 + ++bodyEnd; 131 + } 132 + 133 + // Extract body (between braces) 134 + if (bodyEnd > bodyStart) { 135 + body = css.substr(bodyStart, bodyEnd - bodyStart - 1); 136 + } 137 + 138 + pos = bodyEnd; 139 + return true; 140 + } 141 + 142 + } // anonymous namespace 143 + 144 + // String utilities implementation 145 + 146 + std::string CssParser::normalized(const std::string& s) { 147 + std::string result; 148 + result.reserve(s.size()); 149 + 150 + bool inSpace = true; // Start true to skip leading space 151 + for (const char c : s) { 152 + if (isCssWhitespace(c)) { 153 + if (!inSpace) { 154 + result.push_back(' '); 155 + inSpace = true; 156 + } 157 + } else { 158 + result.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c)))); 159 + inSpace = false; 160 + } 161 + } 162 + 163 + // Remove trailing space 164 + if (!result.empty() && result.back() == ' ') { 165 + result.pop_back(); 166 + } 167 + return result; 168 + } 169 + 170 + std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) { 171 + std::vector<std::string> parts; 172 + size_t start = 0; 173 + 174 + for (size_t i = 0; i <= s.size(); ++i) { 175 + if (i == s.size() || s[i] == delimiter) { 176 + std::string part = s.substr(start, i - start); 177 + std::string trimmed = normalized(part); 178 + if (!trimmed.empty()) { 179 + parts.push_back(trimmed); 180 + } 181 + start = i + 1; 182 + } 183 + } 184 + return parts; 185 + } 186 + 187 + std::vector<std::string> CssParser::splitWhitespace(const std::string& s) { 188 + std::vector<std::string> parts; 189 + size_t start = 0; 190 + bool inWord = false; 191 + 192 + for (size_t i = 0; i <= s.size(); ++i) { 193 + const bool isSpace = i == s.size() || isCssWhitespace(s[i]); 194 + if (isSpace && inWord) { 195 + parts.push_back(s.substr(start, i - start)); 196 + inWord = false; 197 + } else if (!isSpace && !inWord) { 198 + start = i; 199 + inWord = true; 200 + } 201 + } 202 + return parts; 203 + } 204 + 205 + // Property value interpreters 206 + 207 + CssTextAlign CssParser::interpretAlignment(const std::string& val) { 208 + const std::string v = normalized(val); 209 + 210 + if (v == "left" || v == "start") return CssTextAlign::Left; 211 + if (v == "right" || v == "end") return CssTextAlign::Right; 212 + if (v == "center") return CssTextAlign::Center; 213 + if (v == "justify") return CssTextAlign::Justify; 214 + 215 + return CssTextAlign::Left; 216 + } 217 + 218 + CssFontStyle CssParser::interpretFontStyle(const std::string& val) { 219 + const std::string v = normalized(val); 220 + 221 + if (v == "italic" || v == "oblique") return CssFontStyle::Italic; 222 + return CssFontStyle::Normal; 223 + } 224 + 225 + CssFontWeight CssParser::interpretFontWeight(const std::string& val) { 226 + const std::string v = normalized(val); 227 + 228 + // Named values 229 + if (v == "bold" || v == "bolder") return CssFontWeight::Bold; 230 + if (v == "normal" || v == "lighter") return CssFontWeight::Normal; 231 + 232 + // Numeric values: 100-900 233 + // CSS spec: 400 = normal, 700 = bold 234 + // We use: 0-400 = normal, 700+ = bold, 500-600 = normal (conservative) 235 + char* endPtr = nullptr; 236 + const long numericWeight = std::strtol(v.c_str(), &endPtr, 10); 237 + 238 + // If we parsed a number and consumed the whole string 239 + if (endPtr != v.c_str() && *endPtr == '\0') { 240 + return numericWeight >= 700 ? CssFontWeight::Bold : CssFontWeight::Normal; 241 + } 242 + 243 + return CssFontWeight::Normal; 244 + } 245 + 246 + CssTextDecoration CssParser::interpretDecoration(const std::string& val) { 247 + const std::string v = normalized(val); 248 + 249 + // text-decoration can have multiple space-separated values 250 + if (v.find("underline") != std::string::npos) { 251 + return CssTextDecoration::Underline; 252 + } 253 + return CssTextDecoration::None; 254 + } 255 + 256 + CssLength CssParser::interpretLength(const std::string& val) { 257 + const std::string v = normalized(val); 258 + if (v.empty()) return CssLength{}; 259 + 260 + // Find where the number ends 261 + size_t unitStart = v.size(); 262 + for (size_t i = 0; i < v.size(); ++i) { 263 + const char c = v[i]; 264 + if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') { 265 + unitStart = i; 266 + break; 267 + } 268 + } 269 + 270 + const std::string numPart = v.substr(0, unitStart); 271 + const std::string unitPart = v.substr(unitStart); 272 + 273 + // Parse numeric value 274 + char* endPtr = nullptr; 275 + const float numericValue = std::strtof(numPart.c_str(), &endPtr); 276 + if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed 277 + 278 + // Determine unit type (preserve for deferred resolution) 279 + auto unit = CssUnit::Pixels; 280 + if (unitPart == "em") { 281 + unit = CssUnit::Em; 282 + } else if (unitPart == "rem") { 283 + unit = CssUnit::Rem; 284 + } else if (unitPart == "pt") { 285 + unit = CssUnit::Points; 286 + } 287 + // px and unitless default to Pixels 288 + 289 + return CssLength{numericValue, unit}; 290 + } 291 + 292 + int8_t CssParser::interpretSpacing(const std::string& val) { 293 + const std::string v = normalized(val); 294 + if (v.empty()) return 0; 295 + 296 + // For spacing, we convert to "lines" (discrete units for e-ink) 297 + // 1em ≈ 1 line, percentages based on ~30 lines per page 298 + 299 + float multiplier = 0.0f; 300 + size_t unitStart = v.size(); 301 + 302 + for (size_t i = 0; i < v.size(); ++i) { 303 + const char c = v[i]; 304 + if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') { 305 + unitStart = i; 306 + break; 307 + } 308 + } 309 + 310 + const std::string numPart = v.substr(0, unitStart); 311 + const std::string unitPart = v.substr(unitStart); 312 + 313 + if (unitPart == "em" || unitPart == "rem") { 314 + multiplier = 1.0f; // 1em = 1 line 315 + } else if (unitPart == "%") { 316 + multiplier = 0.3f; // ~30 lines per page, so 10% = 3 lines 317 + } else { 318 + return 0; // Unsupported unit for spacing 319 + } 320 + 321 + char* endPtr = nullptr; 322 + const float numericValue = std::strtof(numPart.c_str(), &endPtr); 323 + 324 + if (endPtr == numPart.c_str()) return 0; 325 + 326 + int lines = static_cast<int>(numericValue * multiplier); 327 + 328 + // Clamp to reasonable range (0-2 lines) 329 + if (lines < 0) lines = 0; 330 + if (lines > 2) lines = 2; 331 + 332 + return static_cast<int8_t>(lines); 333 + } 334 + 335 + // Declaration parsing 336 + 337 + CssStyle CssParser::parseDeclarations(const std::string& declBlock) { 338 + CssStyle style; 339 + 340 + // Split declarations by semicolon 341 + const auto declarations = splitOnChar(declBlock, ';'); 342 + 343 + for (const auto& decl : declarations) { 344 + // Find colon separator 345 + const size_t colonPos = decl.find(':'); 346 + if (colonPos == std::string::npos || colonPos == 0) continue; 347 + 348 + std::string propName = normalized(decl.substr(0, colonPos)); 349 + std::string propValue = normalized(decl.substr(colonPos + 1)); 350 + 351 + if (propName.empty() || propValue.empty()) continue; 352 + 353 + // Match property and set value 354 + if (propName == "text-align") { 355 + style.textAlign = interpretAlignment(propValue); 356 + style.defined.textAlign = 1; 357 + } else if (propName == "font-style") { 358 + style.fontStyle = interpretFontStyle(propValue); 359 + style.defined.fontStyle = 1; 360 + } else if (propName == "font-weight") { 361 + style.fontWeight = interpretFontWeight(propValue); 362 + style.defined.fontWeight = 1; 363 + } else if (propName == "text-decoration" || propName == "text-decoration-line") { 364 + style.textDecoration = interpretDecoration(propValue); 365 + style.defined.textDecoration = 1; 366 + } else if (propName == "text-indent") { 367 + style.textIndent = interpretLength(propValue); 368 + style.defined.textIndent = 1; 369 + } else if (propName == "margin-top") { 370 + style.marginTop = interpretLength(propValue); 371 + style.defined.marginTop = 1; 372 + } else if (propName == "margin-bottom") { 373 + style.marginBottom = interpretLength(propValue); 374 + style.defined.marginBottom = 1; 375 + } else if (propName == "margin-left") { 376 + style.marginLeft = interpretLength(propValue); 377 + style.defined.marginLeft = 1; 378 + } else if (propName == "margin-right") { 379 + style.marginRight = interpretLength(propValue); 380 + style.defined.marginRight = 1; 381 + } else if (propName == "margin") { 382 + // Shorthand: 1-4 values for top, right, bottom, left 383 + const auto values = splitWhitespace(propValue); 384 + if (!values.empty()) { 385 + style.marginTop = interpretLength(values[0]); 386 + style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop; 387 + style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop; 388 + style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight; 389 + style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1; 390 + } 391 + } else if (propName == "padding-top") { 392 + style.paddingTop = interpretLength(propValue); 393 + style.defined.paddingTop = 1; 394 + } else if (propName == "padding-bottom") { 395 + style.paddingBottom = interpretLength(propValue); 396 + style.defined.paddingBottom = 1; 397 + } else if (propName == "padding-left") { 398 + style.paddingLeft = interpretLength(propValue); 399 + style.defined.paddingLeft = 1; 400 + } else if (propName == "padding-right") { 401 + style.paddingRight = interpretLength(propValue); 402 + style.defined.paddingRight = 1; 403 + } else if (propName == "padding") { 404 + // Shorthand: 1-4 values for top, right, bottom, left 405 + const auto values = splitWhitespace(propValue); 406 + if (!values.empty()) { 407 + style.paddingTop = interpretLength(values[0]); 408 + style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop; 409 + style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop; 410 + style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight; 411 + style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = 412 + style.defined.paddingLeft = 1; 413 + } 414 + } 415 + } 416 + 417 + return style; 418 + } 419 + 420 + // Rule processing 421 + 422 + void CssParser::processRuleBlock(const std::string& selectorGroup, const std::string& declarations) { 423 + const CssStyle style = parseDeclarations(declarations); 424 + 425 + // Only store if any properties were set 426 + if (!style.defined.anySet()) return; 427 + 428 + // Handle comma-separated selectors 429 + const auto selectors = splitOnChar(selectorGroup, ','); 430 + 431 + for (const auto& sel : selectors) { 432 + // Normalize the selector 433 + std::string key = normalized(sel); 434 + if (key.empty()) continue; 435 + 436 + // Store or merge with existing 437 + auto it = rulesBySelector_.find(key); 438 + if (it != rulesBySelector_.end()) { 439 + it->second.applyOver(style); 440 + } else { 441 + rulesBySelector_[key] = style; 442 + } 443 + } 444 + } 445 + 446 + // Main parsing entry point 447 + 448 + bool CssParser::loadFromStream(FsFile& source) { 449 + if (!source) { 450 + Serial.printf("[%lu] [CSS] Cannot read from invalid file\n", millis()); 451 + return false; 452 + } 453 + 454 + // Read file content 455 + const std::string content = readFileContent(source); 456 + if (content.empty()) { 457 + return true; // Empty file is valid 458 + } 459 + 460 + // Remove comments 461 + const std::string cleaned = stripComments(content); 462 + 463 + // Parse rules 464 + size_t pos = 0; 465 + std::string selector, body; 466 + 467 + while (extractNextRule(cleaned, pos, selector, body)) { 468 + processRuleBlock(selector, body); 469 + } 470 + 471 + Serial.printf("[%lu] [CSS] Parsed %zu rules\n", millis(), rulesBySelector_.size()); 472 + return true; 473 + } 474 + 475 + // Style resolution 476 + 477 + CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const { 478 + CssStyle result; 479 + const std::string tag = normalized(tagName); 480 + 481 + // 1. Apply element-level style (lowest priority) 482 + const auto tagIt = rulesBySelector_.find(tag); 483 + if (tagIt != rulesBySelector_.end()) { 484 + result.applyOver(tagIt->second); 485 + } 486 + 487 + // 2. Apply class styles (medium priority) 488 + if (!classAttr.empty()) { 489 + const auto classes = splitWhitespace(classAttr); 490 + 491 + for (const auto& cls : classes) { 492 + std::string classKey = "." + normalized(cls); 493 + 494 + auto classIt = rulesBySelector_.find(classKey); 495 + if (classIt != rulesBySelector_.end()) { 496 + result.applyOver(classIt->second); 497 + } 498 + } 499 + 500 + // 3. Apply element.class styles (higher priority) 501 + for (const auto& cls : classes) { 502 + std::string combinedKey = tag + "." + normalized(cls); 503 + 504 + auto combinedIt = rulesBySelector_.find(combinedKey); 505 + if (combinedIt != rulesBySelector_.end()) { 506 + result.applyOver(combinedIt->second); 507 + } 508 + } 509 + } 510 + 511 + return result; 512 + } 513 + 514 + // Inline style parsing (static - doesn't need rule database) 515 + 516 + CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return parseDeclarations(styleValue); } 517 + 518 + // Cache serialization 519 + 520 + // Cache format version - increment when format changes 521 + constexpr uint8_t CSS_CACHE_VERSION = 1; 522 + 523 + bool CssParser::saveToCache(FsFile& file) const { 524 + if (!file) { 525 + return false; 526 + } 527 + 528 + // Write version 529 + file.write(CSS_CACHE_VERSION); 530 + 531 + // Write rule count 532 + const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size()); 533 + file.write(reinterpret_cast<const uint8_t*>(&ruleCount), sizeof(ruleCount)); 534 + 535 + // Write each rule: selector string + CssStyle fields 536 + for (const auto& pair : rulesBySelector_) { 537 + // Write selector string (length-prefixed) 538 + const auto selectorLen = static_cast<uint16_t>(pair.first.size()); 539 + file.write(reinterpret_cast<const uint8_t*>(&selectorLen), sizeof(selectorLen)); 540 + file.write(reinterpret_cast<const uint8_t*>(pair.first.data()), selectorLen); 541 + 542 + // Write CssStyle fields (all are POD types) 543 + const CssStyle& style = pair.second; 544 + file.write(static_cast<uint8_t>(style.textAlign)); 545 + file.write(static_cast<uint8_t>(style.fontStyle)); 546 + file.write(static_cast<uint8_t>(style.fontWeight)); 547 + file.write(static_cast<uint8_t>(style.textDecoration)); 548 + 549 + // Write CssLength fields (value + unit) 550 + auto writeLength = [&file](const CssLength& len) { 551 + file.write(reinterpret_cast<const uint8_t*>(&len.value), sizeof(len.value)); 552 + file.write(static_cast<uint8_t>(len.unit)); 553 + }; 554 + 555 + writeLength(style.textIndent); 556 + writeLength(style.marginTop); 557 + writeLength(style.marginBottom); 558 + writeLength(style.marginLeft); 559 + writeLength(style.marginRight); 560 + writeLength(style.paddingTop); 561 + writeLength(style.paddingBottom); 562 + writeLength(style.paddingLeft); 563 + writeLength(style.paddingRight); 564 + 565 + // Write defined flags as uint16_t 566 + uint16_t definedBits = 0; 567 + if (style.defined.textAlign) definedBits |= 1 << 0; 568 + if (style.defined.fontStyle) definedBits |= 1 << 1; 569 + if (style.defined.fontWeight) definedBits |= 1 << 2; 570 + if (style.defined.textDecoration) definedBits |= 1 << 3; 571 + if (style.defined.textIndent) definedBits |= 1 << 4; 572 + if (style.defined.marginTop) definedBits |= 1 << 5; 573 + if (style.defined.marginBottom) definedBits |= 1 << 6; 574 + if (style.defined.marginLeft) definedBits |= 1 << 7; 575 + if (style.defined.marginRight) definedBits |= 1 << 8; 576 + if (style.defined.paddingTop) definedBits |= 1 << 9; 577 + if (style.defined.paddingBottom) definedBits |= 1 << 10; 578 + if (style.defined.paddingLeft) definedBits |= 1 << 11; 579 + if (style.defined.paddingRight) definedBits |= 1 << 12; 580 + file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits)); 581 + } 582 + 583 + Serial.printf("[%lu] [CSS] Saved %u rules to cache\n", millis(), ruleCount); 584 + return true; 585 + } 586 + 587 + bool CssParser::loadFromCache(FsFile& file) { 588 + if (!file) { 589 + return false; 590 + } 591 + 592 + // Clear existing rules 593 + clear(); 594 + 595 + // Read and verify version 596 + uint8_t version = 0; 597 + if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) { 598 + Serial.printf("[%lu] [CSS] Cache version mismatch (got %u, expected %u)\n", millis(), version, CSS_CACHE_VERSION); 599 + return false; 600 + } 601 + 602 + // Read rule count 603 + uint16_t ruleCount = 0; 604 + if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) { 605 + return false; 606 + } 607 + 608 + // Read each rule 609 + for (uint16_t i = 0; i < ruleCount; ++i) { 610 + // Read selector string 611 + uint16_t selectorLen = 0; 612 + if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) { 613 + rulesBySelector_.clear(); 614 + return false; 615 + } 616 + 617 + std::string selector; 618 + selector.resize(selectorLen); 619 + if (file.read(&selector[0], selectorLen) != selectorLen) { 620 + rulesBySelector_.clear(); 621 + return false; 622 + } 623 + 624 + // Read CssStyle fields 625 + CssStyle style; 626 + uint8_t enumVal; 627 + 628 + if (file.read(&enumVal, 1) != 1) { 629 + rulesBySelector_.clear(); 630 + return false; 631 + } 632 + style.textAlign = static_cast<CssTextAlign>(enumVal); 633 + 634 + if (file.read(&enumVal, 1) != 1) { 635 + rulesBySelector_.clear(); 636 + return false; 637 + } 638 + style.fontStyle = static_cast<CssFontStyle>(enumVal); 639 + 640 + if (file.read(&enumVal, 1) != 1) { 641 + rulesBySelector_.clear(); 642 + return false; 643 + } 644 + style.fontWeight = static_cast<CssFontWeight>(enumVal); 645 + 646 + if (file.read(&enumVal, 1) != 1) { 647 + rulesBySelector_.clear(); 648 + return false; 649 + } 650 + style.textDecoration = static_cast<CssTextDecoration>(enumVal); 651 + 652 + // Read CssLength fields 653 + auto readLength = [&file](CssLength& len) -> bool { 654 + if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) { 655 + return false; 656 + } 657 + uint8_t unitVal; 658 + if (file.read(&unitVal, 1) != 1) { 659 + return false; 660 + } 661 + len.unit = static_cast<CssUnit>(unitVal); 662 + return true; 663 + }; 664 + 665 + if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) || 666 + !readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) || 667 + !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) { 668 + rulesBySelector_.clear(); 669 + return false; 670 + } 671 + 672 + // Read defined flags 673 + uint16_t definedBits = 0; 674 + if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) { 675 + rulesBySelector_.clear(); 676 + return false; 677 + } 678 + style.defined.textAlign = (definedBits & 1 << 0) != 0; 679 + style.defined.fontStyle = (definedBits & 1 << 1) != 0; 680 + style.defined.fontWeight = (definedBits & 1 << 2) != 0; 681 + style.defined.textDecoration = (definedBits & 1 << 3) != 0; 682 + style.defined.textIndent = (definedBits & 1 << 4) != 0; 683 + style.defined.marginTop = (definedBits & 1 << 5) != 0; 684 + style.defined.marginBottom = (definedBits & 1 << 6) != 0; 685 + style.defined.marginLeft = (definedBits & 1 << 7) != 0; 686 + style.defined.marginRight = (definedBits & 1 << 8) != 0; 687 + style.defined.paddingTop = (definedBits & 1 << 9) != 0; 688 + style.defined.paddingBottom = (definedBits & 1 << 10) != 0; 689 + style.defined.paddingLeft = (definedBits & 1 << 11) != 0; 690 + style.defined.paddingRight = (definedBits & 1 << 12) != 0; 691 + 692 + rulesBySelector_[selector] = style; 693 + } 694 + 695 + Serial.printf("[%lu] [CSS] Loaded %u rules from cache\n", millis(), ruleCount); 696 + return true; 697 + }
+114
lib/Epub/Epub/css/CssParser.h
··· 1 + #pragma once 2 + 3 + #include <SdFat.h> 4 + 5 + #include <string> 6 + #include <unordered_map> 7 + #include <vector> 8 + 9 + #include "CssStyle.h" 10 + 11 + /** 12 + * Lightweight CSS parser for EPUB stylesheets 13 + * 14 + * Parses CSS files and extracts styling information relevant for e-ink display. 15 + * Uses a two-phase approach: first tokenizes the CSS content, then builds 16 + * a rule database that can be queried during HTML parsing. 17 + * 18 + * Supported selectors: 19 + * - Element selectors: p, div, h1, etc. 20 + * - Class selectors: .classname 21 + * - Combined: element.classname 22 + * - Grouped: selector1, selector2 { } 23 + * 24 + * Not supported (silently ignored): 25 + * - Descendant/child selectors 26 + * - Pseudo-classes and pseudo-elements 27 + * - Media queries (content is skipped) 28 + * - @import, @font-face, etc. 29 + */ 30 + class CssParser { 31 + public: 32 + CssParser() = default; 33 + ~CssParser() = default; 34 + 35 + // Non-copyable 36 + CssParser(const CssParser&) = delete; 37 + CssParser& operator=(const CssParser&) = delete; 38 + 39 + /** 40 + * Load and parse CSS from a file stream. 41 + * Can be called multiple times to accumulate rules from multiple stylesheets. 42 + * @param source Open file handle to read from 43 + * @return true if parsing completed (even if no rules found) 44 + */ 45 + bool loadFromStream(FsFile& source); 46 + 47 + /** 48 + * Look up the style for an HTML element, considering tag name and class attributes. 49 + * Applies CSS cascade: element style < class style < element.class style 50 + * 51 + * @param tagName The HTML element name (e.g., "p", "div") 52 + * @param classAttr The class attribute value (may contain multiple space-separated classes) 53 + * @return Combined style with all applicable rules merged 54 + */ 55 + [[nodiscard]] CssStyle resolveStyle(const std::string& tagName, const std::string& classAttr) const; 56 + 57 + /** 58 + * Parse an inline style attribute string. 59 + * @param styleValue The value of a style="" attribute 60 + * @return Parsed style properties 61 + */ 62 + [[nodiscard]] static CssStyle parseInlineStyle(const std::string& styleValue); 63 + 64 + /** 65 + * Check if any rules have been loaded 66 + */ 67 + [[nodiscard]] bool empty() const { return rulesBySelector_.empty(); } 68 + 69 + /** 70 + * Get count of loaded rule sets 71 + */ 72 + [[nodiscard]] size_t ruleCount() const { return rulesBySelector_.size(); } 73 + 74 + /** 75 + * Clear all loaded rules 76 + */ 77 + void clear() { rulesBySelector_.clear(); } 78 + 79 + /** 80 + * Save parsed CSS rules to a cache file. 81 + * @param file Open file handle to write to 82 + * @return true if cache was written successfully 83 + */ 84 + bool saveToCache(FsFile& file) const; 85 + 86 + /** 87 + * Load CSS rules from a cache file. 88 + * Clears any existing rules before loading. 89 + * @param file Open file handle to read from 90 + * @return true if cache was loaded successfully 91 + */ 92 + bool loadFromCache(FsFile& file); 93 + 94 + private: 95 + // Storage: maps normalized selector -> style properties 96 + std::unordered_map<std::string, CssStyle> rulesBySelector_; 97 + 98 + // Internal parsing helpers 99 + void processRuleBlock(const std::string& selectorGroup, const std::string& declarations); 100 + static CssStyle parseDeclarations(const std::string& declBlock); 101 + 102 + // Individual property value parsers 103 + static CssTextAlign interpretAlignment(const std::string& val); 104 + static CssFontStyle interpretFontStyle(const std::string& val); 105 + static CssFontWeight interpretFontWeight(const std::string& val); 106 + static CssTextDecoration interpretDecoration(const std::string& val); 107 + static CssLength interpretLength(const std::string& val); 108 + static int8_t interpretSpacing(const std::string& val); 109 + 110 + // String utilities 111 + static std::string normalized(const std::string& s); 112 + static std::vector<std::string> splitOnChar(const std::string& s, char delimiter); 113 + static std::vector<std::string> splitWhitespace(const std::string& s); 114 + };
+191
lib/Epub/Epub/css/CssStyle.h
··· 1 + #pragma once 2 + 3 + #include <cstdint> 4 + 5 + // Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings 6 + enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3 }; 7 + enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3 }; 8 + 9 + // Represents a CSS length value with its unit, allowing deferred resolution to pixels 10 + struct CssLength { 11 + float value = 0.0f; 12 + CssUnit unit = CssUnit::Pixels; 13 + 14 + CssLength() = default; 15 + CssLength(const float v, const CssUnit u) : value(v), unit(u) {} 16 + 17 + // Convenience constructor for pixel values (most common case) 18 + explicit CssLength(const float pixels) : value(pixels) {} 19 + 20 + // Resolve to pixels given the current em size (font line height) 21 + [[nodiscard]] float toPixels(const float emSize) const { 22 + switch (unit) { 23 + case CssUnit::Em: 24 + case CssUnit::Rem: 25 + return value * emSize; 26 + case CssUnit::Points: 27 + return value * 1.33f; // Approximate pt to px conversion 28 + default: 29 + return value; 30 + } 31 + } 32 + 33 + // Resolve to int16_t pixels (for BlockStyle fields) 34 + [[nodiscard]] int16_t toPixelsInt16(const float emSize) const { return static_cast<int16_t>(toPixels(emSize)); } 35 + }; 36 + 37 + // Font style options matching CSS font-style property 38 + enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 }; 39 + 40 + // Font weight options - CSS supports 100-900, we simplify to normal/bold 41 + enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 }; 42 + 43 + // Text decoration options 44 + enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 }; 45 + 46 + // Bitmask for tracking which properties have been explicitly set 47 + struct CssPropertyFlags { 48 + uint16_t textAlign : 1; 49 + uint16_t fontStyle : 1; 50 + uint16_t fontWeight : 1; 51 + uint16_t textDecoration : 1; 52 + uint16_t textIndent : 1; 53 + uint16_t marginTop : 1; 54 + uint16_t marginBottom : 1; 55 + uint16_t marginLeft : 1; 56 + uint16_t marginRight : 1; 57 + uint16_t paddingTop : 1; 58 + uint16_t paddingBottom : 1; 59 + uint16_t paddingLeft : 1; 60 + uint16_t paddingRight : 1; 61 + 62 + CssPropertyFlags() 63 + : textAlign(0), 64 + fontStyle(0), 65 + fontWeight(0), 66 + textDecoration(0), 67 + textIndent(0), 68 + marginTop(0), 69 + marginBottom(0), 70 + marginLeft(0), 71 + marginRight(0), 72 + paddingTop(0), 73 + paddingBottom(0), 74 + paddingLeft(0), 75 + paddingRight(0) {} 76 + 77 + [[nodiscard]] bool anySet() const { 78 + return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom || 79 + marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight; 80 + } 81 + 82 + void clearAll() { 83 + textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0; 84 + marginTop = marginBottom = marginLeft = marginRight = 0; 85 + paddingTop = paddingBottom = paddingLeft = paddingRight = 0; 86 + } 87 + }; 88 + 89 + // Represents a collection of CSS style properties 90 + // Only stores properties relevant to e-ink text rendering 91 + // Length values are stored as CssLength (value + unit) for deferred resolution 92 + struct CssStyle { 93 + CssTextAlign textAlign = CssTextAlign::Left; 94 + CssFontStyle fontStyle = CssFontStyle::Normal; 95 + CssFontWeight fontWeight = CssFontWeight::Normal; 96 + CssTextDecoration textDecoration = CssTextDecoration::None; 97 + 98 + CssLength textIndent; // First-line indent (deferred resolution) 99 + CssLength marginTop; // Vertical spacing before block 100 + CssLength marginBottom; // Vertical spacing after block 101 + CssLength marginLeft; // Horizontal spacing left of block 102 + CssLength marginRight; // Horizontal spacing right of block 103 + CssLength paddingTop; // Padding before 104 + CssLength paddingBottom; // Padding after 105 + CssLength paddingLeft; // Padding left 106 + CssLength paddingRight; // Padding right 107 + 108 + CssPropertyFlags defined; // Tracks which properties were explicitly set 109 + 110 + // Apply properties from another style, only overwriting if the other style 111 + // has that property explicitly defined 112 + void applyOver(const CssStyle& base) { 113 + if (base.hasTextAlign()) { 114 + textAlign = base.textAlign; 115 + defined.textAlign = 1; 116 + } 117 + if (base.hasFontStyle()) { 118 + fontStyle = base.fontStyle; 119 + defined.fontStyle = 1; 120 + } 121 + if (base.hasFontWeight()) { 122 + fontWeight = base.fontWeight; 123 + defined.fontWeight = 1; 124 + } 125 + if (base.hasTextDecoration()) { 126 + textDecoration = base.textDecoration; 127 + defined.textDecoration = 1; 128 + } 129 + if (base.hasTextIndent()) { 130 + textIndent = base.textIndent; 131 + defined.textIndent = 1; 132 + } 133 + if (base.hasMarginTop()) { 134 + marginTop = base.marginTop; 135 + defined.marginTop = 1; 136 + } 137 + if (base.hasMarginBottom()) { 138 + marginBottom = base.marginBottom; 139 + defined.marginBottom = 1; 140 + } 141 + if (base.hasMarginLeft()) { 142 + marginLeft = base.marginLeft; 143 + defined.marginLeft = 1; 144 + } 145 + if (base.hasMarginRight()) { 146 + marginRight = base.marginRight; 147 + defined.marginRight = 1; 148 + } 149 + if (base.hasPaddingTop()) { 150 + paddingTop = base.paddingTop; 151 + defined.paddingTop = 1; 152 + } 153 + if (base.hasPaddingBottom()) { 154 + paddingBottom = base.paddingBottom; 155 + defined.paddingBottom = 1; 156 + } 157 + if (base.hasPaddingLeft()) { 158 + paddingLeft = base.paddingLeft; 159 + defined.paddingLeft = 1; 160 + } 161 + if (base.hasPaddingRight()) { 162 + paddingRight = base.paddingRight; 163 + defined.paddingRight = 1; 164 + } 165 + } 166 + 167 + [[nodiscard]] bool hasTextAlign() const { return defined.textAlign; } 168 + [[nodiscard]] bool hasFontStyle() const { return defined.fontStyle; } 169 + [[nodiscard]] bool hasFontWeight() const { return defined.fontWeight; } 170 + [[nodiscard]] bool hasTextDecoration() const { return defined.textDecoration; } 171 + [[nodiscard]] bool hasTextIndent() const { return defined.textIndent; } 172 + [[nodiscard]] bool hasMarginTop() const { return defined.marginTop; } 173 + [[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; } 174 + [[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; } 175 + [[nodiscard]] bool hasMarginRight() const { return defined.marginRight; } 176 + [[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; } 177 + [[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; } 178 + [[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; } 179 + [[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; } 180 + 181 + void reset() { 182 + textAlign = CssTextAlign::Left; 183 + fontStyle = CssFontStyle::Normal; 184 + fontWeight = CssFontWeight::Normal; 185 + textDecoration = CssTextDecoration::None; 186 + textIndent = CssLength{}; 187 + marginTop = marginBottom = marginLeft = marginRight = CssLength{}; 188 + paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{}; 189 + defined.clearAll(); 190 + } 191 + };
+239 -55
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
··· 22 22 const char* ITALIC_TAGS[] = {"i", "em"}; 23 23 constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); 24 24 25 + const char* UNDERLINE_TAGS[] = {"u", "ins"}; 26 + constexpr int NUM_UNDERLINE_TAGS = sizeof(UNDERLINE_TAGS) / sizeof(UNDERLINE_TAGS[0]); 27 + 25 28 const char* IMAGE_TAGS[] = {"img"}; 26 29 constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]); 27 30 ··· 40 43 return false; 41 44 } 42 45 46 + bool isHeaderOrBlock(const char* name) { 47 + return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS); 48 + } 49 + 50 + // Update effective bold/italic/underline based on block style and inline style stack 51 + void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { 52 + // Start with block-level styles 53 + effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold; 54 + effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic; 55 + effectiveUnderline = 56 + currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline; 57 + 58 + // Apply inline style stack in order 59 + for (const auto& entry : inlineStyleStack) { 60 + if (entry.hasBold) { 61 + effectiveBold = entry.bold; 62 + } 63 + if (entry.hasItalic) { 64 + effectiveItalic = entry.italic; 65 + } 66 + if (entry.hasUnderline) { 67 + effectiveUnderline = entry.underline; 68 + } 69 + } 70 + } 71 + 43 72 // flush the contents of partWordBuffer to currentTextBlock 44 73 void ChapterHtmlSlimParser::flushPartWordBuffer() { 45 - // determine font style 74 + // Determine font style from depth-based tracking and CSS effective style 75 + const bool isBold = boldUntilDepth < depth || effectiveBold; 76 + const bool isItalic = italicUntilDepth < depth || effectiveItalic; 77 + const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline; 78 + 79 + // Combine style flags using bitwise OR 46 80 EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; 47 - if (boldUntilDepth < depth && italicUntilDepth < depth) { 48 - fontStyle = EpdFontFamily::BOLD_ITALIC; 49 - } else if (boldUntilDepth < depth) { 50 - fontStyle = EpdFontFamily::BOLD; 51 - } else if (italicUntilDepth < depth) { 52 - fontStyle = EpdFontFamily::ITALIC; 81 + if (isBold) { 82 + fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::BOLD); 83 + } 84 + if (isItalic) { 85 + fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC); 86 + } 87 + if (isUnderline) { 88 + fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE); 53 89 } 90 + 54 91 // flush the buffer 55 92 partWordBuffer[partWordBufferIndex] = '\0'; 56 93 currentTextBlock->addWord(partWordBuffer, fontStyle); ··· 58 95 } 59 96 60 97 // start a new text block if needed 61 - void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) { 98 + void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { 62 99 if (currentTextBlock) { 63 100 // already have a text block running and it is empty - just reuse it 64 101 if (currentTextBlock->isEmpty()) { 65 - currentTextBlock->setStyle(style); 102 + // Merge with existing block style to accumulate CSS styling from parent block elements. 103 + // This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the 104 + // div's margin should be preserved, even though it has no direct text content. 105 + currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle)); 66 106 return; 67 107 } 68 108 69 109 makePages(); 70 110 } 71 - currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing, hyphenationEnabled)); 111 + currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle)); 72 112 } 73 113 74 114 void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { ··· 80 120 return; 81 121 } 82 122 123 + // Extract class and style attributes for CSS processing 124 + std::string classAttr; 125 + std::string styleAttr; 126 + if (atts != nullptr) { 127 + for (int i = 0; atts[i]; i += 2) { 128 + if (strcmp(atts[i], "class") == 0) { 129 + classAttr = atts[i + 1]; 130 + } else if (strcmp(atts[i], "style") == 0) { 131 + styleAttr = atts[i + 1]; 132 + } 133 + } 134 + } 135 + 136 + auto centeredBlockStyle = BlockStyle(); 137 + centeredBlockStyle.textAlignDefined = true; 138 + centeredBlockStyle.alignment = CssTextAlign::Center; 139 + 83 140 // Special handling for tables - show placeholder text instead of dropping silently 84 141 if (strcmp(name, "table") == 0) { 85 142 // Add placeholder text 86 - self->startNewTextBlock(TextBlock::CENTER_ALIGN); 143 + self->startNewTextBlock(centeredBlockStyle); 87 144 88 145 self->italicUntilDepth = min(self->italicUntilDepth, self->depth); 89 - // Advance depth before processing character data (like you would for a element with text) 146 + // Advance depth before processing character data (like you would for an element with text) 90 147 self->depth += 1; 91 148 self->characterData(userData, "[Table omitted]", strlen("[Table omitted]")); 92 149 ··· 111 168 112 169 Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); 113 170 114 - self->startNewTextBlock(TextBlock::CENTER_ALIGN); 171 + self->startNewTextBlock(centeredBlockStyle); 115 172 self->italicUntilDepth = min(self->italicUntilDepth, self->depth); 116 - // Advance depth before processing character data (like you would for a element with text) 173 + // Advance depth before processing character data (like you would for an element with text) 117 174 self->depth += 1; 118 175 self->characterData(userData, alt.c_str(), alt.length()); 119 176 ··· 141 198 } 142 199 } 143 200 144 - if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { 145 - self->startNewTextBlock(TextBlock::CENTER_ALIGN); 146 - self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); 147 - self->depth += 1; 148 - return; 201 + // Compute CSS style for this element 202 + CssStyle cssStyle; 203 + if (self->cssParser) { 204 + // Get combined tag + class styles 205 + cssStyle = self->cssParser->resolveStyle(name, classAttr); 206 + // Merge inline style (highest priority) 207 + if (!styleAttr.empty()) { 208 + CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr); 209 + cssStyle.applyOver(inlineStyle); 210 + } 149 211 } 150 212 151 - if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { 213 + const float emSize = static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression; 214 + const auto userAlignment = static_cast<CssTextAlign>(self->paragraphAlignment); 215 + 216 + if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { 217 + self->currentCssStyle = cssStyle; 218 + self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment)); 219 + self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); 220 + self->updateEffectiveInlineStyle(); 221 + } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { 152 222 if (strcmp(name, "br") == 0) { 153 223 if (self->partWordBufferIndex > 0) { 154 224 // flush word preceding <br/> to currentTextBlock before calling startNewTextBlock 155 225 self->flushPartWordBuffer(); 156 226 } 157 - self->startNewTextBlock(self->currentTextBlock->getStyle()); 158 - self->depth += 1; 159 - return; 160 - } 227 + self->startNewTextBlock(self->currentTextBlock->getBlockStyle()); 228 + } else { 229 + self->currentCssStyle = cssStyle; 230 + self->startNewTextBlock(BlockStyle::fromCssStyle(cssStyle, emSize, userAlignment)); 231 + self->updateEffectiveInlineStyle(); 161 232 162 - self->startNewTextBlock(static_cast<TextBlock::Style>(self->paragraphAlignment)); 163 - if (strcmp(name, "li") == 0) { 164 - self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); 233 + if (strcmp(name, "li") == 0) { 234 + self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); 235 + } 165 236 } 166 - 167 - self->depth += 1; 168 - return; 169 - } 170 - 171 - if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { 237 + } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { 238 + self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth); 239 + // Push inline style entry for underline tag 240 + StyleStackEntry entry; 241 + entry.depth = self->depth; // Track depth for matching pop 242 + entry.hasUnderline = true; 243 + entry.underline = true; 244 + if (cssStyle.hasFontWeight()) { 245 + entry.hasBold = true; 246 + entry.bold = cssStyle.fontWeight == CssFontWeight::Bold; 247 + } 248 + if (cssStyle.hasFontStyle()) { 249 + entry.hasItalic = true; 250 + entry.italic = cssStyle.fontStyle == CssFontStyle::Italic; 251 + } 252 + self->inlineStyleStack.push_back(entry); 253 + self->updateEffectiveInlineStyle(); 254 + } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { 172 255 self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); 173 - self->depth += 1; 174 - return; 175 - } 176 - 177 - if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { 256 + // Push inline style entry for bold tag 257 + StyleStackEntry entry; 258 + entry.depth = self->depth; // Track depth for matching pop 259 + entry.hasBold = true; 260 + entry.bold = true; 261 + if (cssStyle.hasFontStyle()) { 262 + entry.hasItalic = true; 263 + entry.italic = cssStyle.fontStyle == CssFontStyle::Italic; 264 + } 265 + if (cssStyle.hasTextDecoration()) { 266 + entry.hasUnderline = true; 267 + entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; 268 + } 269 + self->inlineStyleStack.push_back(entry); 270 + self->updateEffectiveInlineStyle(); 271 + } else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { 178 272 self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); 179 - self->depth += 1; 180 - return; 273 + // Push inline style entry for italic tag 274 + StyleStackEntry entry; 275 + entry.depth = self->depth; // Track depth for matching pop 276 + entry.hasItalic = true; 277 + entry.italic = true; 278 + if (cssStyle.hasFontWeight()) { 279 + entry.hasBold = true; 280 + entry.bold = cssStyle.fontWeight == CssFontWeight::Bold; 281 + } 282 + if (cssStyle.hasTextDecoration()) { 283 + entry.hasUnderline = true; 284 + entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; 285 + } 286 + self->inlineStyleStack.push_back(entry); 287 + self->updateEffectiveInlineStyle(); 288 + } else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) { 289 + // Handle span and other inline elements for CSS styling 290 + if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) { 291 + StyleStackEntry entry; 292 + entry.depth = self->depth; // Track depth for matching pop 293 + if (cssStyle.hasFontWeight()) { 294 + entry.hasBold = true; 295 + entry.bold = cssStyle.fontWeight == CssFontWeight::Bold; 296 + } 297 + if (cssStyle.hasFontStyle()) { 298 + entry.hasItalic = true; 299 + entry.italic = cssStyle.fontStyle == CssFontStyle::Italic; 300 + } 301 + if (cssStyle.hasTextDecoration()) { 302 + entry.hasUnderline = true; 303 + entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; 304 + } 305 + self->inlineStyleStack.push_back(entry); 306 + self->updateEffectiveInlineStyle(); 307 + } 181 308 } 182 309 183 310 // Unprocessed tag, just increasing depth and continue forward ··· 239 366 void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { 240 367 auto* self = static_cast<ChapterHtmlSlimParser*>(userData); 241 368 369 + // Check if any style state will change after we decrement depth 370 + // If so, we MUST flush the partWordBuffer with the CURRENT style first 371 + // Note: depth hasn't been decremented yet, so we check against (depth - 1) 372 + const bool willPopStyleStack = 373 + !self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1; 374 + const bool willClearBold = self->boldUntilDepth == self->depth - 1; 375 + const bool willClearItalic = self->italicUntilDepth == self->depth - 1; 376 + const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1; 377 + 378 + const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline; 379 + const bool headerOrBlockTag = isHeaderOrBlock(name); 380 + 381 + // Flush buffer with current style BEFORE any style changes 242 382 if (self->partWordBufferIndex > 0) { 243 - // Only flush out part word buffer if we're closing a block tag or are at the top of the HTML file. 244 - // We don't want to flush out content when closing inline tags like <span>. 245 - // Currently this also flushes out on closing <b> and <i> tags, but they are line tags so that shouldn't happen, 246 - // text styling needs to be overhauled to fix it. 247 - const bool shouldBreakText = 248 - matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || 249 - matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || 250 - strcmp(name, "table") == 0 || matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1; 383 + // Flush if style will change OR if we're closing a block/structural element 384 + const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || 385 + matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || 386 + matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 || 387 + matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1; 251 388 252 - if (shouldBreakText) { 389 + if (shouldFlush) { 253 390 self->flushPartWordBuffer(); 254 391 } 255 392 } ··· 261 398 self->skipUntilDepth = INT_MAX; 262 399 } 263 400 264 - // Leaving bold 401 + // Leaving bold tag 265 402 if (self->boldUntilDepth == self->depth) { 266 403 self->boldUntilDepth = INT_MAX; 267 404 } 268 405 269 - // Leaving italic 406 + // Leaving italic tag 270 407 if (self->italicUntilDepth == self->depth) { 271 408 self->italicUntilDepth = INT_MAX; 272 409 } 410 + 411 + // Leaving underline tag 412 + if (self->underlineUntilDepth == self->depth) { 413 + self->underlineUntilDepth = INT_MAX; 414 + } 415 + 416 + // Pop from inline style stack if we pushed an entry at this depth 417 + // This handles all inline elements: b, i, u, span, etc. 418 + if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) { 419 + self->inlineStyleStack.pop_back(); 420 + self->updateEffectiveInlineStyle(); 421 + } 422 + 423 + // Clear block style when leaving header or block elements 424 + if (headerOrBlockTag) { 425 + self->currentCssStyle.reset(); 426 + self->updateEffectiveInlineStyle(); 427 + } 273 428 } 274 429 275 430 bool ChapterHtmlSlimParser::parseAndBuildPages() { 276 - startNewTextBlock((TextBlock::Style)this->paragraphAlignment); 431 + auto paragraphAlignmentBlockStyle = BlockStyle(); 432 + paragraphAlignmentBlockStyle.textAlignDefined = true; 433 + paragraphAlignmentBlockStyle.alignment = static_cast<CssTextAlign>(this->paragraphAlignment); 434 + startNewTextBlock(paragraphAlignmentBlockStyle); 277 435 278 436 const XML_Parser parser = XML_ParserCreate(nullptr); 279 437 int done; ··· 362 520 currentPageNextY = 0; 363 521 } 364 522 365 - currentPage->elements.push_back(std::make_shared<PageLine>(line, 0, currentPageNextY)); 523 + // Apply horizontal left inset (margin + padding) as x position offset 524 + const int16_t xOffset = line->getBlockStyle().leftInset(); 525 + currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY)); 366 526 currentPageNextY += lineHeight; 367 527 } 368 528 ··· 378 538 } 379 539 380 540 const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; 541 + 542 + // Apply top spacing before the paragraph (stored in pixels) 543 + const BlockStyle& blockStyle = currentTextBlock->getBlockStyle(); 544 + if (blockStyle.marginTop > 0) { 545 + currentPageNextY += blockStyle.marginTop; 546 + } 547 + if (blockStyle.paddingTop > 0) { 548 + currentPageNextY += blockStyle.paddingTop; 549 + } 550 + 551 + // Calculate effective width accounting for horizontal margins/padding 552 + const int horizontalInset = blockStyle.totalHorizontalInset(); 553 + const uint16_t effectiveWidth = 554 + (horizontalInset < viewportWidth) ? static_cast<uint16_t>(viewportWidth - horizontalInset) : viewportWidth; 555 + 381 556 currentTextBlock->layoutAndExtractLines( 382 - renderer, fontId, viewportWidth, 557 + renderer, fontId, effectiveWidth, 383 558 [this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); }); 384 - // Extra paragraph spacing if enabled 559 + 560 + // Apply bottom spacing after the paragraph (stored in pixels) 561 + if (blockStyle.marginBottom > 0) { 562 + currentPageNextY += blockStyle.marginBottom; 563 + } 564 + if (blockStyle.paddingBottom > 0) { 565 + currentPageNextY += blockStyle.paddingBottom; 566 + } 567 + 568 + // Extra paragraph spacing if enabled (default behavior) 385 569 if (extraParagraphSpacing) { 386 570 currentPageNextY += lineHeight / 2; 387 571 }
+24 -3
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
··· 8 8 9 9 #include "../ParsedText.h" 10 10 #include "../blocks/TextBlock.h" 11 + #include "../css/CssParser.h" 12 + #include "../css/CssStyle.h" 11 13 12 14 class Page; 13 15 class GfxRenderer; ··· 23 25 int skipUntilDepth = INT_MAX; 24 26 int boldUntilDepth = INT_MAX; 25 27 int italicUntilDepth = INT_MAX; 28 + int underlineUntilDepth = INT_MAX; 26 29 // buffer for building up words from characters, will auto break if longer than this 27 30 // leave one char at end for null pointer 28 31 char partWordBuffer[MAX_WORD_SIZE + 1] = {}; ··· 37 40 uint16_t viewportWidth; 38 41 uint16_t viewportHeight; 39 42 bool hyphenationEnabled; 43 + const CssParser* cssParser; 40 44 41 - void startNewTextBlock(TextBlock::Style style); 45 + // Style tracking (replaces depth-based approach) 46 + struct StyleStackEntry { 47 + int depth = 0; 48 + bool hasBold = false, bold = false; 49 + bool hasItalic = false, italic = false; 50 + bool hasUnderline = false, underline = false; 51 + }; 52 + std::vector<StyleStackEntry> inlineStyleStack; 53 + CssStyle currentCssStyle; 54 + bool effectiveBold = false; 55 + bool effectiveItalic = false; 56 + bool effectiveUnderline = false; 57 + 58 + void updateEffectiveInlineStyle(); 59 + void startNewTextBlock(const BlockStyle& blockStyle); 42 60 void flushPartWordBuffer(); 43 61 void makePages(); 44 62 // XML callbacks ··· 52 70 const uint8_t paragraphAlignment, const uint16_t viewportWidth, 53 71 const uint16_t viewportHeight, const bool hyphenationEnabled, 54 72 const std::function<void(std::unique_ptr<Page>)>& completePageFn, 55 - const std::function<void()>& popupFn = nullptr) 73 + const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr) 74 + 56 75 : filepath(filepath), 57 76 renderer(renderer), 58 77 fontId(fontId), ··· 63 82 viewportHeight(viewportHeight), 64 83 hyphenationEnabled(hyphenationEnabled), 65 84 completePageFn(completePageFn), 66 - popupFn(popupFn) {} 85 + popupFn(popupFn), 86 + cssParser(cssParser) {} 87 + 67 88 ~ChapterHtmlSlimParser() = default; 68 89 bool parseAndBuildPages(); 69 90 void addLineToPage(std::shared_ptr<TextBlock> line);
+6
lib/Epub/Epub/parsers/ContentOpfParser.cpp
··· 8 8 9 9 namespace { 10 10 constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; 11 + constexpr char MEDIA_TYPE_CSS[] = "text/css"; 11 12 constexpr char itemCacheFile[] = "/.items.bin"; 12 13 } // namespace 13 14 ··· 216 217 Serial.printf("[%lu] [COF] Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s\n", millis(), 217 218 href.c_str()); 218 219 } 220 + } 221 + 222 + // Collect CSS files 223 + if (mediaType == MEDIA_TYPE_CSS) { 224 + self->cssFiles.push_back(href); 219 225 } 220 226 221 227 // EPUB 3: Check for nav document (properties contains "nav")
+1
lib/Epub/Epub/parsers/ContentOpfParser.h
··· 64 64 std::string tocNavPath; // EPUB 3 nav document path 65 65 std::string coverItemHref; 66 66 std::string textReferenceHref; 67 + std::vector<std::string> cssFiles; // CSS stylesheet paths 67 68 68 69 explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize, 69 70 BookMetadataCache* cache)
+14
lib/GfxRenderer/GfxRenderer.cpp
··· 470 470 return fontMap.at(fontId).getGlyph(' ', EpdFontFamily::REGULAR)->advanceX; 471 471 } 472 472 473 + int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const { 474 + if (fontMap.count(fontId) == 0) { 475 + Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); 476 + return 0; 477 + } 478 + 479 + uint32_t cp; 480 + int width = 0; 481 + while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 482 + width += fontMap.at(fontId).getGlyph(cp, EpdFontFamily::REGULAR)->advanceX; 483 + } 484 + return width; 485 + } 486 + 473 487 int GfxRenderer::getFontAscenderSize(const int fontId) const { 474 488 if (fontMap.count(fontId) == 0) { 475 489 Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
+1
lib/GfxRenderer/GfxRenderer.h
··· 78 78 void drawText(int fontId, int x, int y, const char* text, bool black = true, 79 79 EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; 80 80 int getSpaceWidth(int fontId) const; 81 + int getTextAdvanceX(int fontId, const char* text) const; 81 82 int getFontAscenderSize(int fontId) const; 82 83 int getLineHeight(int fontId) const; 83 84 std::string truncatedText(int fontId, const char* text, int maxWidth,
+2 -1
src/activities/boot_sleep/SleepActivity.cpp
··· 238 238 } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { 239 239 // Handle EPUB file 240 240 Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); 241 - if (!lastEpub.load()) { 241 + // Skip loading css since we only need metadata here 242 + if (!lastEpub.load(true, true)) { 242 243 Serial.println("[SLP] Failed to load last epub"); 243 244 return renderDefaultSleepScreen(); 244 245 }
+2 -1
src/activities/home/HomeActivity.cpp
··· 52 52 // If epub, try to load the metadata for title/author and cover 53 53 if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { 54 54 Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); 55 - epub.load(false); 55 + // Skip loading css since we only need metadata here 56 + epub.load(false, true); 56 57 if (!epub.getTitle().empty()) { 57 58 lastBookTitle = std::string(epub.getTitle()); 58 59 }