perf: Improve large CSS files handling (#779)
Some checks failed
CI (build) / clang-format (push) Has been cancelled
CI (build) / cppcheck (push) Has been cancelled
CI (build) / build (push) Has been cancelled
CI (build) / Test Status (push) Has been cancelled

## Summary

Closes #766. Thank you for the help @bramschulting!

**What is the goal of this PR?** 
- First and foremost, fix issue #766.
- Through working on that, I realized the current CSS parsing/loading
code can be improved dramatically for large files and still had
additional performance improvements to be made, even with EPUBs with
small CSS.

**What changes are included?**
- Stream CSS parsing and reuse normalization buffers to cut allocations
- Add rule limits and selector validation to release rules and free up
memory when needed
- Skip CSS parsing/loading entirely when "Book's Embedded Style" is off

## Additional Context

- My test EPUB has been updated
[here](https://github.com/jdk2pq/css-test-epub) to include a very large
CSS file to test this out

---

### 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? _**YES**_, Codex
This commit is contained in:
Jake Kenneally
2026-02-15 12:22:42 -05:00
committed by GitHub
parent 5816ab2a47
commit 46c2109f1f
8 changed files with 373 additions and 298 deletions

View File

@@ -208,30 +208,14 @@ bool Epub::parseTocNavFile() const {
return true; return true;
} }
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
bool Epub::loadCssRulesFromCache() const {
FsFile cssCacheFile;
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
if (cssParser->loadFromCache(cssCacheFile)) {
cssCacheFile.close();
LOG_DBG("EBP", "Loaded CSS rules from cache");
return true;
}
cssCacheFile.close();
LOG_DBG("EBP", "CSS cache invalid, reparsing");
}
return false;
}
void Epub::parseCssFiles() const { void Epub::parseCssFiles() const {
if (cssFiles.empty()) { if (cssFiles.empty()) {
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles"); LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
} }
// Try to load from CSS cache first // See if we have a cached version of the CSS rules
if (!loadCssRulesFromCache()) { if (!cssParser->hasCache()) {
// Cache miss - parse CSS files // No cache yet - parse CSS files
for (const auto& cssPath : cssFiles) { for (const auto& cssPath : cssFiles) {
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str()); LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
@@ -262,11 +246,10 @@ void Epub::parseCssFiles() const {
} }
// Save to cache for next time // Save to cache for next time
FsFile cssCacheFile; if (!cssParser->saveToCache()) {
if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) { LOG_ERR("EBP", "Failed to save CSS rules to cache");
cssParser->saveToCache(cssCacheFile);
cssCacheFile.close();
} }
cssParser->clear();
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size()); LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
} }
@@ -279,11 +262,11 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Initialize spine/TOC cache // Initialize spine/TOC cache
bookMetadataCache.reset(new BookMetadataCache(cachePath)); bookMetadataCache.reset(new BookMetadataCache(cachePath));
// Always create CssParser - needed for inline style parsing even without CSS files // Always create CssParser - needed for inline style parsing even without CSS files
cssParser.reset(new CssParser()); cssParser.reset(new CssParser(cachePath));
// Try to load existing cache first // Try to load existing cache first
if (bookMetadataCache->load()) { if (bookMetadataCache->load()) {
if (!skipLoadingCss && !loadCssRulesFromCache()) { if (!skipLoadingCss && !cssParser->hasCache()) {
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files"); LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
// to get CSS file list // to get CSS file list
if (!parseContentOpf(bookMetadataCache->coreMetadata)) { if (!parseContentOpf(bookMetadataCache->coreMetadata)) {

View File

@@ -35,8 +35,6 @@ class Epub {
bool parseTocNcxFile() const; bool parseTocNcxFile() const;
bool parseTocNavFile() const; bool parseTocNavFile() const;
void parseCssFiles() const; void parseCssFiles() const;
std::string getCssRulesCache() const;
bool loadCssRulesFromCache() const;
public: public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
@@ -73,5 +71,5 @@ class Epub {
size_t getBookSize() const; size_t getBookSize() const;
float calculateProgress(int currentSpineIndex, float currentSpineRead) const; float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
const CssParser* getCssParser() const { return cssParser.get(); } CssParser* getCssParser() const { return cssParser.get(); }
}; };

View File

@@ -181,11 +181,20 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
viewportHeight, hyphenationEnabled, embeddedStyle); viewportHeight, hyphenationEnabled, embeddedStyle);
std::vector<uint32_t> lut = {}; std::vector<uint32_t> lut = {};
CssParser* cssParser = nullptr;
if (embeddedStyle) {
cssParser = epub->getCssParser();
if (cssParser) {
if (!cssParser->loadFromCache()) {
LOG_ERR("SCT", "Failed to load CSS from cache");
}
}
}
ChapterHtmlSlimParser visitor( ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr); embeddedStyle, popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage()); Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
@@ -194,6 +203,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
LOG_ERR("SCT", "Failed to parse XML and build pages"); LOG_ERR("SCT", "Failed to parse XML and build pages");
file.close(); file.close();
Storage.remove(filePath.c_str()); Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
}
return false; return false;
} }
@@ -220,6 +232,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, pageCount); serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset); serialization::writePod(file, lutOffset);
file.close(); file.close();
if (cssParser) {
cssParser->clear();
}
return true; return true;
} }

View File

@@ -1,144 +1,57 @@
#include "CssParser.h" #include "CssParser.h"
#include <Arduino.h>
#include <Logging.h> #include <Logging.h>
#include <algorithm> #include <algorithm>
#include <array>
#include <cctype> #include <cctype>
#include <string_view>
namespace { namespace {
// Stack-allocated string buffer to avoid heap reallocations during parsing
// Provides string-like interface with fixed capacity
struct StackBuffer {
static constexpr size_t CAPACITY = 1024;
char data[CAPACITY];
size_t len = 0;
void push_back(char c) {
if (len < CAPACITY - 1) {
data[len++] = c;
}
}
void clear() { len = 0; }
bool empty() const { return len == 0; }
size_t size() const { return len; }
// Get string view of current content (zero-copy)
std::string_view view() const { return std::string_view(data, len); }
// Convert to string for passing to functions (single allocation)
std::string str() const { return std::string(data, len); }
};
// Buffer size for reading CSS files // Buffer size for reading CSS files
constexpr size_t READ_BUFFER_SIZE = 512; constexpr size_t READ_BUFFER_SIZE = 512;
// Maximum CSS file size we'll process (prevent memory issues) // Maximum number of CSS rules to store in the selector map
constexpr size_t MAX_CSS_SIZE = 64 * 1024; // Prevents unbounded memory growth from pathological CSS files
constexpr size_t MAX_RULES = 1500;
// Minimum free heap required to apply CSS during rendering
// If below this threshold, we skip CSS to avoid display artifacts.
constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024;
// Maximum length for a single selector string
// Prevents parsing of extremely long or malformed selectors
constexpr size_t MAX_SELECTOR_LENGTH = 256;
// Check if character is CSS whitespace // Check if character is CSS whitespace
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; } bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
// Read entire file into string (with size limit)
std::string readFileContent(FsFile& file) {
std::string content;
content.reserve(std::min(static_cast<size_t>(file.size()), MAX_CSS_SIZE));
char buffer[READ_BUFFER_SIZE];
while (file.available() && content.size() < MAX_CSS_SIZE) {
const int bytesRead = file.read(buffer, sizeof(buffer));
if (bytesRead <= 0) break;
content.append(buffer, bytesRead);
}
return content;
}
// Remove CSS comments (/* ... */) from content
std::string stripComments(const std::string& css) {
std::string result;
result.reserve(css.size());
size_t pos = 0;
while (pos < css.size()) {
// Look for start of comment
if (pos + 1 < css.size() && css[pos] == '/' && css[pos + 1] == '*') {
// Find end of comment
const size_t endPos = css.find("*/", pos + 2);
if (endPos == std::string::npos) {
// Unterminated comment - skip rest of file
break;
}
pos = endPos + 2;
} else {
result.push_back(css[pos]);
++pos;
}
}
return result;
}
// Skip @-rules (like @media, @import, @font-face)
// Returns position after the @-rule
size_t skipAtRule(const std::string& css, const size_t start) {
// Find the end - either semicolon (simple @-rule) or matching brace
size_t pos = start + 1; // Skip the '@'
// Skip identifier
while (pos < css.size() && (std::isalnum(css[pos]) || css[pos] == '-')) {
++pos;
}
// Look for { or ;
int braceDepth = 0;
while (pos < css.size()) {
const char c = css[pos];
if (c == '{') {
++braceDepth;
} else if (c == '}') {
--braceDepth;
if (braceDepth == 0) {
return pos + 1;
}
} else if (c == ';' && braceDepth == 0) {
return pos + 1;
}
++pos;
}
return css.size();
}
// Extract next rule from CSS content
// Returns true if a rule was found, with selector and body filled
bool extractNextRule(const std::string& css, size_t& pos, std::string& selector, std::string& body) {
selector.clear();
body.clear();
// Skip whitespace and @-rules until we find a regular rule
while (pos < css.size()) {
// Skip whitespace
while (pos < css.size() && isCssWhitespace(css[pos])) {
++pos;
}
if (pos >= css.size()) return false;
// Handle @-rules iteratively (avoids recursion/stack overflow)
if (css[pos] == '@') {
pos = skipAtRule(css, pos);
continue; // Try again after skipping the @-rule
}
break; // Found start of a regular rule
}
if (pos >= css.size()) return false;
// Find opening brace
const size_t bracePos = css.find('{', pos);
if (bracePos == std::string::npos) return false;
// Extract selector (everything before the brace)
selector = css.substr(pos, bracePos - pos);
// Find matching closing brace
int depth = 1;
const size_t bodyStart = bracePos + 1;
size_t bodyEnd = bodyStart;
while (bodyEnd < css.size() && depth > 0) {
if (css[bodyEnd] == '{')
++depth;
else if (css[bodyEnd] == '}')
--depth;
++bodyEnd;
}
// Extract body (between braces)
if (bodyEnd > bodyStart) {
body = css.substr(bodyStart, bodyEnd - bodyStart - 1);
}
pos = bodyEnd;
return true;
}
} // anonymous namespace } // anonymous namespace
// String utilities implementation // String utilities implementation
@@ -167,6 +80,28 @@ std::string CssParser::normalized(const std::string& s) {
return result; return result;
} }
void CssParser::normalizedInto(const std::string& s, std::string& out) {
out.clear();
out.reserve(s.size());
bool inSpace = true; // Start true to skip leading space
for (const char c : s) {
if (isCssWhitespace(c)) {
if (!inSpace) {
out.push_back(' ');
inSpace = true;
}
} else {
out.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
inSpace = false;
}
}
if (!out.empty() && out.back() == ' ') {
out.pop_back();
}
}
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) { std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
std::vector<std::string> parts; std::vector<std::string> parts;
size_t start = 0; size_t start = 0;
@@ -290,99 +225,47 @@ CssLength CssParser::interpretLength(const std::string& val) {
return CssLength{numericValue, unit}; return CssLength{numericValue, unit};
} }
int8_t CssParser::interpretSpacing(const std::string& val) {
const std::string v = normalized(val);
if (v.empty()) return 0;
// For spacing, we convert to "lines" (discrete units for e-ink)
// 1em ≈ 1 line, percentages based on ~30 lines per page
float multiplier = 0.0f;
size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) {
const char c = v[i];
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
unitStart = i;
break;
}
}
const std::string numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart);
if (unitPart == "em" || unitPart == "rem") {
multiplier = 1.0f; // 1em = 1 line
} else if (unitPart == "%") {
multiplier = 0.3f; // ~30 lines per page, so 10% = 3 lines
} else {
return 0; // Unsupported unit for spacing
}
char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
if (endPtr == numPart.c_str()) return 0;
int lines = static_cast<int>(numericValue * multiplier);
// Clamp to reasonable range (0-2 lines)
if (lines < 0) lines = 0;
if (lines > 2) lines = 2;
return static_cast<int8_t>(lines);
}
// Declaration parsing // Declaration parsing
CssStyle CssParser::parseDeclarations(const std::string& declBlock) { void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
CssStyle style; std::string& propValueBuf) {
// Split declarations by semicolon
const auto declarations = splitOnChar(declBlock, ';');
for (const auto& decl : declarations) {
// Find colon separator
const size_t colonPos = decl.find(':'); const size_t colonPos = decl.find(':');
if (colonPos == std::string::npos || colonPos == 0) continue; if (colonPos == std::string::npos || colonPos == 0) return;
std::string propName = normalized(decl.substr(0, colonPos)); normalizedInto(decl.substr(0, colonPos), propNameBuf);
std::string propValue = normalized(decl.substr(colonPos + 1)); normalizedInto(decl.substr(colonPos + 1), propValueBuf);
if (propName.empty() || propValue.empty()) continue; if (propNameBuf.empty() || propValueBuf.empty()) return;
// Match property and set value if (propNameBuf == "text-align") {
if (propName == "text-align") { style.textAlign = interpretAlignment(propValueBuf);
style.textAlign = interpretAlignment(propValue);
style.defined.textAlign = 1; style.defined.textAlign = 1;
} else if (propName == "font-style") { } else if (propNameBuf == "font-style") {
style.fontStyle = interpretFontStyle(propValue); style.fontStyle = interpretFontStyle(propValueBuf);
style.defined.fontStyle = 1; style.defined.fontStyle = 1;
} else if (propName == "font-weight") { } else if (propNameBuf == "font-weight") {
style.fontWeight = interpretFontWeight(propValue); style.fontWeight = interpretFontWeight(propValueBuf);
style.defined.fontWeight = 1; style.defined.fontWeight = 1;
} else if (propName == "text-decoration" || propName == "text-decoration-line") { } else if (propNameBuf == "text-decoration" || propNameBuf == "text-decoration-line") {
style.textDecoration = interpretDecoration(propValue); style.textDecoration = interpretDecoration(propValueBuf);
style.defined.textDecoration = 1; style.defined.textDecoration = 1;
} else if (propName == "text-indent") { } else if (propNameBuf == "text-indent") {
style.textIndent = interpretLength(propValue); style.textIndent = interpretLength(propValueBuf);
style.defined.textIndent = 1; style.defined.textIndent = 1;
} else if (propName == "margin-top") { } else if (propNameBuf == "margin-top") {
style.marginTop = interpretLength(propValue); style.marginTop = interpretLength(propValueBuf);
style.defined.marginTop = 1; style.defined.marginTop = 1;
} else if (propName == "margin-bottom") { } else if (propNameBuf == "margin-bottom") {
style.marginBottom = interpretLength(propValue); style.marginBottom = interpretLength(propValueBuf);
style.defined.marginBottom = 1; style.defined.marginBottom = 1;
} else if (propName == "margin-left") { } else if (propNameBuf == "margin-left") {
style.marginLeft = interpretLength(propValue); style.marginLeft = interpretLength(propValueBuf);
style.defined.marginLeft = 1; style.defined.marginLeft = 1;
} else if (propName == "margin-right") { } else if (propNameBuf == "margin-right") {
style.marginRight = interpretLength(propValue); style.marginRight = interpretLength(propValueBuf);
style.defined.marginRight = 1; style.defined.marginRight = 1;
} else if (propName == "margin") { } else if (propNameBuf == "margin") {
// Shorthand: 1-4 values for top, right, bottom, left const auto values = splitWhitespace(propValueBuf);
const auto values = splitWhitespace(propValue);
if (!values.empty()) { if (!values.empty()) {
style.marginTop = interpretLength(values[0]); style.marginTop = interpretLength(values[0]);
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop; style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
@@ -390,51 +273,82 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight; style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1; style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
} }
} else if (propName == "padding-top") { } else if (propNameBuf == "padding-top") {
style.paddingTop = interpretLength(propValue); style.paddingTop = interpretLength(propValueBuf);
style.defined.paddingTop = 1; style.defined.paddingTop = 1;
} else if (propName == "padding-bottom") { } else if (propNameBuf == "padding-bottom") {
style.paddingBottom = interpretLength(propValue); style.paddingBottom = interpretLength(propValueBuf);
style.defined.paddingBottom = 1; style.defined.paddingBottom = 1;
} else if (propName == "padding-left") { } else if (propNameBuf == "padding-left") {
style.paddingLeft = interpretLength(propValue); style.paddingLeft = interpretLength(propValueBuf);
style.defined.paddingLeft = 1; style.defined.paddingLeft = 1;
} else if (propName == "padding-right") { } else if (propNameBuf == "padding-right") {
style.paddingRight = interpretLength(propValue); style.paddingRight = interpretLength(propValueBuf);
style.defined.paddingRight = 1; style.defined.paddingRight = 1;
} else if (propName == "padding") { } else if (propNameBuf == "padding") {
// Shorthand: 1-4 values for top, right, bottom, left const auto values = splitWhitespace(propValueBuf);
const auto values = splitWhitespace(propValue);
if (!values.empty()) { if (!values.empty()) {
style.paddingTop = interpretLength(values[0]); style.paddingTop = interpretLength(values[0]);
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop; style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop; style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight; style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
style.defined.paddingLeft = 1; 1;
} }
} }
} }
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
CssStyle style;
std::string propNameBuf;
std::string propValueBuf;
size_t start = 0;
for (size_t i = 0; i <= declBlock.size(); ++i) {
if (i == declBlock.size() || declBlock[i] == ';') {
if (i > start) {
const size_t len = i - start;
std::string decl = declBlock.substr(start, len);
if (!decl.empty()) {
parseDeclarationIntoStyle(decl, style, propNameBuf, propValueBuf);
}
}
start = i + 1;
}
}
return style; return style;
} }
// Rule processing // Rule processing
void CssParser::processRuleBlock(const std::string& selectorGroup, const std::string& declarations) { void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style) {
const CssStyle style = parseDeclarations(declarations); // Check if we've reached the rule limit before processing
if (rulesBySelector_.size() >= MAX_RULES) {
// Only store if any properties were set LOG_DBG("CSS", "Reached max rules limit (%zu), stopping CSS parsing", MAX_RULES);
if (!style.defined.anySet()) return; return;
}
// Handle comma-separated selectors // Handle comma-separated selectors
const auto selectors = splitOnChar(selectorGroup, ','); const auto selectors = splitOnChar(selectorGroup, ',');
for (const auto& sel : selectors) { for (const auto& sel : selectors) {
// Validate selector length before processing
if (sel.size() > MAX_SELECTOR_LENGTH) {
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
continue;
}
// Normalize the selector // Normalize the selector
std::string key = normalized(sel); std::string key = normalized(sel);
if (key.empty()) continue; if (key.empty()) continue;
// Skip if this would exceed the rule limit
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
return;
}
// Store or merge with existing // Store or merge with existing
auto it = rulesBySelector_.find(key); auto it = rulesBySelector_.find(key);
if (it != rulesBySelector_.end()) { if (it != rulesBySelector_.end()) {
@@ -453,30 +367,158 @@ bool CssParser::loadFromStream(FsFile& source) {
return false; return false;
} }
// Read file content size_t totalRead = 0;
const std::string content = readFileContent(source);
if (content.empty()) { // Use stack-allocated buffers for parsing to avoid heap reallocations
return true; // Empty file is valid StackBuffer selector;
StackBuffer declBuffer;
// Keep these as std::string since they're passed by reference to parseDeclarationIntoStyle
std::string propNameBuf;
std::string propValueBuf;
bool inComment = false;
bool maybeSlash = false;
bool prevStar = false;
bool inAtRule = false;
int atDepth = 0;
int bodyDepth = 0;
bool skippingRule = false;
CssStyle currentStyle;
auto handleChar = [&](const char c) {
if (inAtRule) {
if (c == '{') {
++atDepth;
} else if (c == '}') {
if (atDepth > 0) --atDepth;
if (atDepth == 0) inAtRule = false;
} else if (c == ';' && atDepth == 0) {
inAtRule = false;
}
return;
} }
// Remove comments if (bodyDepth == 0) {
const std::string cleaned = stripComments(content); if (selector.empty() && isCssWhitespace(c)) {
return;
// Parse rules }
size_t pos = 0; if (c == '@' && selector.empty()) {
std::string selector, body; inAtRule = true;
atDepth = 0;
while (extractNextRule(cleaned, pos, selector, body)) { return;
processRuleBlock(selector, body); }
if (c == '{') {
bodyDepth = 1;
currentStyle = CssStyle{};
declBuffer.clear();
if (selector.size() > MAX_SELECTOR_LENGTH * 4) {
skippingRule = true;
}
return;
}
selector.push_back(c);
return;
} }
LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size()); // bodyDepth > 0
if (c == '{') {
++bodyDepth;
return;
}
if (c == '}') {
--bodyDepth;
if (bodyDepth == 0) {
if (!skippingRule && !declBuffer.empty()) {
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
}
if (!skippingRule) {
processRuleBlockWithStyle(selector.str(), currentStyle);
}
selector.clear();
declBuffer.clear();
skippingRule = false;
return;
}
return;
}
if (bodyDepth > 1) {
return;
}
if (!skippingRule) {
if (c == ';') {
if (!declBuffer.empty()) {
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
declBuffer.clear();
}
} else {
declBuffer.push_back(c);
}
}
};
char buffer[READ_BUFFER_SIZE];
while (source.available()) {
int bytesRead = source.read(buffer, sizeof(buffer));
if (bytesRead <= 0) break;
totalRead += static_cast<size_t>(bytesRead);
for (int i = 0; i < bytesRead; ++i) {
const char c = buffer[i];
if (inComment) {
if (prevStar && c == '/') {
inComment = false;
prevStar = false;
continue;
}
prevStar = c == '*';
continue;
}
if (maybeSlash) {
if (c == '*') {
inComment = true;
maybeSlash = false;
prevStar = false;
continue;
}
handleChar('/');
maybeSlash = false;
// fall through to process current char
}
if (c == '/') {
maybeSlash = true;
continue;
}
handleChar(c);
}
}
if (maybeSlash) {
handleChar('/');
}
LOG_DBG("CSS", "Parsed %zu rules from %zu bytes", rulesBySelector_.size(), totalRead);
return true; return true;
} }
// Style resolution // Style resolution
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const { CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
static bool lowHeapWarningLogged = false;
if (ESP.getFreeHeap() < MIN_FREE_HEAP_FOR_CSS) {
if (!lowHeapWarningLogged) {
lowHeapWarningLogged = true;
LOG_DBG("CSS", "Warning: low heap (%u bytes) below MIN_FREE_HEAP_FOR_CSS (%u), returning empty style",
ESP.getFreeHeap(), static_cast<unsigned>(MIN_FREE_HEAP_FOR_CSS));
}
return CssStyle{};
}
CssStyle result; CssStyle result;
const std::string tag = normalized(tagName); const std::string tag = normalized(tagName);
@@ -521,9 +563,17 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
// Cache format version - increment when format changes // Cache format version - increment when format changes
constexpr uint8_t CSS_CACHE_VERSION = 2; constexpr uint8_t CSS_CACHE_VERSION = 2;
constexpr char rulesCache[] = "/css_rules.cache";
bool CssParser::saveToCache(FsFile& file) const { bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
if (!file) {
bool CssParser::saveToCache() const {
if (cachePath.empty()) {
return false;
}
FsFile file;
if (!Storage.openFileForWrite("CSS", cachePath + rulesCache, file)) {
return false; return false;
} }
@@ -583,11 +633,17 @@ bool CssParser::saveToCache(FsFile& file) const {
} }
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount); LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
file.close();
return true; return true;
} }
bool CssParser::loadFromCache(FsFile& file) { bool CssParser::loadFromCache() {
if (!file) { if (cachePath.empty()) {
return false;
}
FsFile file;
if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) {
return false; return false;
} }
@@ -598,12 +654,14 @@ bool CssParser::loadFromCache(FsFile& file) {
uint8_t version = 0; uint8_t version = 0;
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) { if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION); LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
file.close();
return false; return false;
} }
// Read rule count // Read rule count
uint16_t ruleCount = 0; uint16_t ruleCount = 0;
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) { if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
file.close();
return false; return false;
} }
@@ -613,6 +671,7 @@ bool CssParser::loadFromCache(FsFile& file) {
uint16_t selectorLen = 0; uint16_t selectorLen = 0;
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) { if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
@@ -620,6 +679,7 @@ bool CssParser::loadFromCache(FsFile& file) {
selector.resize(selectorLen); selector.resize(selectorLen);
if (file.read(&selector[0], selectorLen) != selectorLen) { if (file.read(&selector[0], selectorLen) != selectorLen) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
@@ -629,24 +689,28 @@ bool CssParser::loadFromCache(FsFile& file) {
if (file.read(&enumVal, 1) != 1) { if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
style.textAlign = static_cast<CssTextAlign>(enumVal); style.textAlign = static_cast<CssTextAlign>(enumVal);
if (file.read(&enumVal, 1) != 1) { if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
style.fontStyle = static_cast<CssFontStyle>(enumVal); style.fontStyle = static_cast<CssFontStyle>(enumVal);
if (file.read(&enumVal, 1) != 1) { if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
style.fontWeight = static_cast<CssFontWeight>(enumVal); style.fontWeight = static_cast<CssFontWeight>(enumVal);
if (file.read(&enumVal, 1) != 1) { if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
style.textDecoration = static_cast<CssTextDecoration>(enumVal); style.textDecoration = static_cast<CssTextDecoration>(enumVal);
@@ -668,6 +732,7 @@ bool CssParser::loadFromCache(FsFile& file) {
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) || !readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) { !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
@@ -675,6 +740,7 @@ bool CssParser::loadFromCache(FsFile& file) {
uint16_t definedBits = 0; uint16_t definedBits = 0;
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) { if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
rulesBySelector_.clear(); rulesBySelector_.clear();
file.close();
return false; return false;
} }
style.defined.textAlign = (definedBits & 1 << 0) != 0; style.defined.textAlign = (definedBits & 1 << 0) != 0;
@@ -695,5 +761,6 @@ bool CssParser::loadFromCache(FsFile& file) {
} }
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount); LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
file.close();
return true; return true;
} }

View File

@@ -4,6 +4,7 @@
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <utility>
#include <vector> #include <vector>
#include "CssStyle.h" #include "CssStyle.h"
@@ -29,7 +30,7 @@
*/ */
class CssParser { class CssParser {
public: public:
CssParser() = default; explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
~CssParser() = default; ~CssParser() = default;
// Non-copyable // Non-copyable
@@ -76,28 +77,35 @@ class CssParser {
*/ */
void clear() { rulesBySelector_.clear(); } void clear() { rulesBySelector_.clear(); }
/**
* Check if CSS rules cache file exists
*/
bool hasCache() const;
/** /**
* Save parsed CSS rules to a cache file. * Save parsed CSS rules to a cache file.
* @param file Open file handle to write to
* @return true if cache was written successfully * @return true if cache was written successfully
*/ */
bool saveToCache(FsFile& file) const; bool saveToCache() const;
/** /**
* Load CSS rules from a cache file. * Load CSS rules from a cache file.
* Clears any existing rules before loading. * Clears any existing rules before loading.
* @param file Open file handle to read from
* @return true if cache was loaded successfully * @return true if cache was loaded successfully
*/ */
bool loadFromCache(FsFile& file); bool loadFromCache();
private: private:
// Storage: maps normalized selector -> style properties // Storage: maps normalized selector -> style properties
std::unordered_map<std::string, CssStyle> rulesBySelector_; std::unordered_map<std::string, CssStyle> rulesBySelector_;
std::string cachePath;
// Internal parsing helpers // Internal parsing helpers
void processRuleBlock(const std::string& selectorGroup, const std::string& declarations); void processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style);
static CssStyle parseDeclarations(const std::string& declBlock); static CssStyle parseDeclarations(const std::string& declBlock);
static void parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
std::string& propValueBuf);
// Individual property value parsers // Individual property value parsers
static CssTextAlign interpretAlignment(const std::string& val); static CssTextAlign interpretAlignment(const std::string& val);
@@ -105,10 +113,10 @@ class CssParser {
static CssFontWeight interpretFontWeight(const std::string& val); static CssFontWeight interpretFontWeight(const std::string& val);
static CssTextDecoration interpretDecoration(const std::string& val); static CssTextDecoration interpretDecoration(const std::string& val);
static CssLength interpretLength(const std::string& val); static CssLength interpretLength(const std::string& val);
static int8_t interpretSpacing(const std::string& val);
// String utilities // String utilities
static std::string normalized(const std::string& s); static std::string normalized(const std::string& s);
static void normalizedInto(const std::string& s, std::string& out);
static std::vector<std::string> splitOnChar(const std::string& s, char delimiter); static std::vector<std::string> splitOnChar(const std::string& s, char delimiter);
static std::vector<std::string> splitWhitespace(const std::string& s); static std::vector<std::string> splitWhitespace(const std::string& s);
}; };

View File

@@ -27,10 +27,13 @@ build_flags =
# https://libexpat.github.io/doc/api/latest/#XML_GE # https://libexpat.github.io/doc/api/latest/#XML_GE
-DXML_GE=0 -DXML_GE=0
-DXML_CONTEXT_BYTES=1024 -DXML_CONTEXT_BYTES=1024
-std=c++2a -std=gnu++2a
# Enable UTF-8 long file names in SdFat # Enable UTF-8 long file names in SdFat
-DUSE_UTF8_LONG_NAMES=1 -DUSE_UTF8_LONG_NAMES=1
build_unflags =
-std=gnu++11
; Board configuration ; Board configuration
board_build.flash_mode = dio board_build.flash_mode = dio
board_build.flash_size = 16MB board_build.flash_size = 16MB

View File

@@ -88,7 +88,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
// If epub, try to load the metadata for title/author and cover // If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
Epub epub(path, "/.crosspoint"); Epub epub(path, "/.crosspoint");
epub.load(false); epub.load(false, true);
return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()}; return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()};
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") || } else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) { StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {

View File

@@ -2,6 +2,7 @@
#include <HalStorage.h> #include <HalStorage.h>
#include "CrossPointSettings.h"
#include "Epub.h" #include "Epub.h"
#include "EpubReaderActivity.h" #include "EpubReaderActivity.h"
#include "Txt.h" #include "Txt.h"
@@ -35,7 +36,7 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
} }
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint")); auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
if (epub->load()) { if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
return epub; return epub;
} }