## Summary Use `std::string_view` and case-insensitive comparisons to avoid string allocations during CSS parsing. **Hot path:** `resolveStyle` (called per HTML start tag during chapter rendering) now does zero heap allocations. Previously it allocated a normalized tag string, a vector of class strings, and a composite key per class. For a chapter with ~2000 tags × 2 classes each, that's ~12 000 small short-lived allocations eliminated per page render — primarily a heap-fragmentation win on the ESP32-C3's ~380KB RAM. **Cold path:** CSS load no longer allocates per-rule selector vectors or per-token strings; `splitOnChar`/`splitWhitespace` are gone, replaced with callback-based tokenization (`forEachDelimitedToken`). **Behavioral notes:** - The selector `unordered_map` now uses an ASCII-case-insensitive hash/equal. Selectors are stored with their original case rather than pre-lowercased; the observable lookup result is unchanged. - `stripTrailingImportant` is now case-insensitive (per CSS spec; previously matched only lowercase `!important`). **Cache compatibility:** `CSS_CACHE_VERSION` unchanged. Old caches (lowercase selectors) load correctly under the new lookup; new caches will contain verbatim-case selectors — both forms work. --- ### AI Usage Did you use AI tools to help write this code? _**PARTIALLY**_
947 lines
32 KiB
C++
947 lines
32 KiB
C++
#include "CssParser.h"
|
|
|
|
#include <Arduino.h>
|
|
#include <Logging.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
#include <charconv>
|
|
#include <cstring>
|
|
#include <string_view>
|
|
|
|
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); }
|
|
operator std::string_view() const noexcept { return view(); }
|
|
};
|
|
|
|
// Buffer size for reading CSS files
|
|
constexpr size_t READ_BUFFER_SIZE = 512;
|
|
|
|
// Maximum number of CSS rules to store in the selector map
|
|
// 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
|
|
constexpr bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
|
|
|
|
constexpr std::string_view trimCssWhitespace(std::string_view s) {
|
|
while (!s.empty() && isCssWhitespace(s.front())) s.remove_prefix(1);
|
|
while (!s.empty() && isCssWhitespace(s.back())) s.remove_suffix(1);
|
|
return s;
|
|
}
|
|
|
|
constexpr char asciiToLower(const char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c; }
|
|
|
|
// Case-insensitive equality on ASCII. lowercaseKeyword MUST already be
|
|
// lowercase; CSS keywords are ASCII by spec so byte-wise tolower is safe.
|
|
constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKeyword) {
|
|
return std::equal(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
|
|
[](char a, char b) { return asciiToLower(a) == b; });
|
|
}
|
|
|
|
// Case-insensitive ASCII substring search. Only needed by text-decoration,
|
|
// which accepts multi-value strings like "underline solid red".
|
|
constexpr bool icontainsAscii(std::string_view value, std::string_view lowercaseKeyword) {
|
|
return std::search(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
|
|
[](char a, char b) { return asciiToLower(a) == b; }) != value.end();
|
|
}
|
|
|
|
// Walk s and invoke fn(token) for each non-empty run between delimiters.
|
|
// Tokens are boundary-trimmed and yielded as string_views into s; no
|
|
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
|
|
// emitted. `isDelimiter` is invoked once per character.
|
|
template <typename Pred, typename F>
|
|
void forEachDelimitedToken(std::string_view s, Pred isDelimiter, F&& fn) {
|
|
size_t start = 0;
|
|
for (size_t i = 0; i <= s.size(); ++i) {
|
|
if (i == s.size() || isDelimiter(s[i])) {
|
|
const std::string_view trimmed = trimCssWhitespace(s.substr(start, i - start));
|
|
if (!trimmed.empty()) {
|
|
fn(trimmed);
|
|
}
|
|
start = i + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// FNV-1a per Fowler/Noll/Vo, sized to match size_t on the target. The firmware
|
|
// runs on a 32-bit core where size_t is 32 bits, so naively using the 64-bit
|
|
// constants would silently truncate FNV_PRIME to a non-prime and wreck hash
|
|
// distribution. The selection below picks the canonical 32- or 64-bit
|
|
// constants at compile time so the same source works in a 64-bit host
|
|
// simulator. `fnv1aMix` is the per-byte mix step; callers apply any
|
|
// byte-level transform (e.g. asciiToLower) first.
|
|
static_assert(sizeof(size_t) == 4 || sizeof(size_t) == 8, "FNV constants are only defined for 32- or 64-bit size_t");
|
|
constexpr size_t FNV_OFFSET_BASIS =
|
|
sizeof(size_t) == 8 ? static_cast<size_t>(14695981039346656037ULL) : static_cast<size_t>(2166136261U);
|
|
constexpr size_t FNV_PRIME =
|
|
sizeof(size_t) == 8 ? static_cast<size_t>(1099511628211ULL) : static_cast<size_t>(16777619U);
|
|
|
|
constexpr size_t fnv1aMix(size_t hash, unsigned char byte) { return (hash ^ byte) * FNV_PRIME; }
|
|
|
|
// Parse the entirety of s as a number into `out`. Accepts an optional leading
|
|
// '+' (which std::from_chars rejects by spec) so callers can pass CSS-style
|
|
// signed numbers without manual trimming. Returns false on empty input, a
|
|
// non-numeric suffix, or any from_chars error.
|
|
template <typename T>
|
|
bool tryParseNumber(std::string_view s, T& out) {
|
|
const char* begin = s.data();
|
|
const char* end = s.data() + s.size();
|
|
if (begin < end && *begin == '+') ++begin;
|
|
const auto r = std::from_chars(begin, end, out);
|
|
return r.ec == std::errc{} && r.ptr == end;
|
|
}
|
|
|
|
// Collect up to 4 whitespace-separated tokens for a CSS edge-value shorthand
|
|
// (margin, padding, and the border-* family). Returns the number of tokens
|
|
// written; extras are silently dropped. Callers apply the 1/2/3/4-value
|
|
// fallback rule using the returned count.
|
|
size_t collectEdgeValueTokens(std::string_view s, std::string_view (&out)[4]) {
|
|
size_t count = 0;
|
|
forEachDelimitedToken(s, isCssWhitespace, [&](std::string_view tok) {
|
|
if (count < 4) out[count++] = tok;
|
|
});
|
|
return count;
|
|
}
|
|
|
|
std::string_view stripTrailingImportant(std::string_view value) {
|
|
constexpr std::string_view IMPORTANT = "!important";
|
|
|
|
while (!value.empty() && isCssWhitespace(value.back())) {
|
|
value.remove_suffix(1);
|
|
}
|
|
|
|
if (value.size() < IMPORTANT.size()) {
|
|
return value;
|
|
}
|
|
|
|
const size_t suffixPos = value.size() - IMPORTANT.size();
|
|
if (!iequalsAscii(value.substr(suffixPos), IMPORTANT)) {
|
|
return value;
|
|
}
|
|
|
|
value.remove_suffix(IMPORTANT.size());
|
|
while (!value.empty() && isCssWhitespace(value.back())) {
|
|
value.remove_suffix(1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
// Transparent case-insensitive hash/equal. Bodies live here (rather than
|
|
// inline in the header) so they can share the anonymous-namespace asciiToLower
|
|
// with the other ASCII helpers in this translation unit.
|
|
|
|
size_t CssParser::SvHash::operator()(std::string_view sv) const noexcept {
|
|
size_t h = FNV_OFFSET_BASIS;
|
|
for (char c : sv) h = fnv1aMix(h, asciiToLower(c));
|
|
return h;
|
|
}
|
|
|
|
size_t CssParser::SvHash::operator()(const std::string& s) const noexcept { return operator()(std::string_view(s)); }
|
|
|
|
size_t CssParser::SvHash::operator()(CompositeKey k) const noexcept {
|
|
// Hash the case-folded concatenation of every piece without materializing
|
|
// it — the running hash continues across pieces as if they were one buffer.
|
|
size_t h = FNV_OFFSET_BASIS;
|
|
for (std::string_view piece : k.pieces) {
|
|
for (char c : piece) h = fnv1aMix(h, asciiToLower(c));
|
|
}
|
|
return h;
|
|
}
|
|
|
|
bool CssParser::SvEqual::operator()(std::string_view a, std::string_view b) const noexcept {
|
|
if (a.size() != b.size()) return false;
|
|
for (size_t i = 0; i < a.size(); ++i) {
|
|
if (asciiToLower(a[i]) != asciiToLower(b[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool CssParser::SvEqual::operator()(const std::string& a, std::string_view b) const noexcept {
|
|
return operator()(std::string_view(a), b);
|
|
}
|
|
|
|
bool CssParser::SvEqual::operator()(std::string_view a, const std::string& b) const noexcept {
|
|
return operator()(a, std::string_view(b));
|
|
}
|
|
|
|
bool CssParser::SvEqual::operator()(const std::string& a, const std::string& b) const noexcept {
|
|
return operator()(std::string_view(a), std::string_view(b));
|
|
}
|
|
|
|
bool CssParser::SvEqual::operator()(CompositeKey k, std::string_view sv) const noexcept {
|
|
size_t total = 0;
|
|
for (std::string_view piece : k.pieces) total += piece.size();
|
|
if (total != sv.size()) return false;
|
|
size_t i = 0;
|
|
for (std::string_view piece : k.pieces) {
|
|
for (char c : piece) {
|
|
if (asciiToLower(c) != asciiToLower(sv[i++])) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool CssParser::SvEqual::operator()(std::string_view sv, CompositeKey k) const noexcept { return operator()(k, sv); }
|
|
|
|
// Property value interpreters
|
|
|
|
CssTextAlign CssParser::interpretAlignment(std::string_view val) {
|
|
val = trimCssWhitespace(val);
|
|
|
|
if (iequalsAscii(val, "left") || iequalsAscii(val, "start")) return CssTextAlign::Left;
|
|
if (iequalsAscii(val, "right") || iequalsAscii(val, "end")) return CssTextAlign::Right;
|
|
if (iequalsAscii(val, "center")) return CssTextAlign::Center;
|
|
if (iequalsAscii(val, "justify")) return CssTextAlign::Justify;
|
|
|
|
return CssTextAlign::Left;
|
|
}
|
|
|
|
CssFontStyle CssParser::interpretFontStyle(std::string_view val) {
|
|
val = trimCssWhitespace(val);
|
|
|
|
if (iequalsAscii(val, "italic") || iequalsAscii(val, "oblique")) return CssFontStyle::Italic;
|
|
return CssFontStyle::Normal;
|
|
}
|
|
|
|
CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
|
|
val = trimCssWhitespace(val);
|
|
|
|
// Named values
|
|
if (iequalsAscii(val, "bold") || iequalsAscii(val, "bolder")) return CssFontWeight::Bold;
|
|
if (iequalsAscii(val, "normal") || iequalsAscii(val, "lighter")) return CssFontWeight::Normal;
|
|
|
|
// Numeric values: 100-900
|
|
// CSS spec: 400 = normal, 700 = bold
|
|
// We use: 0-400 = normal, 700+ = bold, 500-600 = normal (conservative)
|
|
long numericWeight = 0;
|
|
if (tryParseNumber(val, numericWeight)) {
|
|
return numericWeight >= 700 ? CssFontWeight::Bold : CssFontWeight::Normal;
|
|
}
|
|
return CssFontWeight::Normal;
|
|
}
|
|
|
|
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
|
// text-decoration can have multiple space-separated values
|
|
if (icontainsAscii(val, "underline")) {
|
|
return CssTextDecoration::Underline;
|
|
}
|
|
return CssTextDecoration::None;
|
|
}
|
|
|
|
CssLength CssParser::interpretLength(std::string_view val) {
|
|
CssLength result;
|
|
tryInterpretLength(val, result);
|
|
return result;
|
|
}
|
|
|
|
bool CssParser::tryInterpretLength(std::string_view val, CssLength& out) {
|
|
val = trimCssWhitespace(val);
|
|
if (val.empty()) {
|
|
out = CssLength{};
|
|
return false;
|
|
}
|
|
|
|
size_t unitStart = val.size();
|
|
for (size_t i = 0; i < val.size(); ++i) {
|
|
const char c = val[i];
|
|
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
|
|
unitStart = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
float numericValue;
|
|
if (!tryParseNumber(val.substr(0, unitStart), numericValue)) {
|
|
out = CssLength{};
|
|
return false; // No number parsed (e.g. auto, inherit, initial)
|
|
}
|
|
|
|
const std::string_view unitPart = val.substr(unitStart);
|
|
auto unit = CssUnit::Pixels;
|
|
if (iequalsAscii(unitPart, "em")) {
|
|
unit = CssUnit::Em;
|
|
} else if (iequalsAscii(unitPart, "rem")) {
|
|
unit = CssUnit::Rem;
|
|
} else if (iequalsAscii(unitPart, "pt")) {
|
|
unit = CssUnit::Points;
|
|
} else if (unitPart == "%") {
|
|
unit = CssUnit::Percent;
|
|
}
|
|
|
|
out = CssLength{numericValue, unit};
|
|
return true;
|
|
}
|
|
|
|
// Declaration parsing
|
|
|
|
void CssParser::parseDeclarationIntoStyle(std::string_view decl, CssStyle& style) {
|
|
const size_t colonPos = decl.find(':');
|
|
if (colonPos == std::string_view::npos || colonPos == 0) return;
|
|
|
|
const std::string_view name = trimCssWhitespace(decl.substr(0, colonPos));
|
|
const std::string_view value = trimCssWhitespace(decl.substr(colonPos + 1));
|
|
|
|
if (name.empty() || value.empty()) return;
|
|
|
|
if (iequalsAscii(name, "text-align")) {
|
|
style.textAlign = interpretAlignment(value);
|
|
style.defined.textAlign = 1;
|
|
} else if (iequalsAscii(name, "font-style")) {
|
|
style.fontStyle = interpretFontStyle(value);
|
|
style.defined.fontStyle = 1;
|
|
} else if (iequalsAscii(name, "font-weight")) {
|
|
style.fontWeight = interpretFontWeight(value);
|
|
style.defined.fontWeight = 1;
|
|
} else if (iequalsAscii(name, "text-decoration") || iequalsAscii(name, "text-decoration-line")) {
|
|
style.textDecoration = interpretDecoration(value);
|
|
style.defined.textDecoration = 1;
|
|
} else if (iequalsAscii(name, "text-indent")) {
|
|
style.textIndent = interpretLength(value);
|
|
style.defined.textIndent = 1;
|
|
} else if (iequalsAscii(name, "margin-top")) {
|
|
style.marginTop = interpretLength(value);
|
|
style.defined.marginTop = 1;
|
|
} else if (iequalsAscii(name, "margin-bottom")) {
|
|
style.marginBottom = interpretLength(value);
|
|
style.defined.marginBottom = 1;
|
|
} else if (iequalsAscii(name, "margin-left")) {
|
|
style.marginLeft = interpretLength(value);
|
|
style.defined.marginLeft = 1;
|
|
} else if (iequalsAscii(name, "margin-right")) {
|
|
style.marginRight = interpretLength(value);
|
|
style.defined.marginRight = 1;
|
|
} else if (iequalsAscii(name, "margin")) {
|
|
std::string_view margins[4];
|
|
const size_t count = collectEdgeValueTokens(value, margins);
|
|
if (count > 0) {
|
|
style.marginTop = interpretLength(margins[0]);
|
|
style.marginRight = count >= 2 ? interpretLength(margins[1]) : style.marginTop;
|
|
style.marginBottom = count >= 3 ? interpretLength(margins[2]) : style.marginTop;
|
|
style.marginLeft = count >= 4 ? interpretLength(margins[3]) : style.marginRight;
|
|
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
|
}
|
|
} else if (iequalsAscii(name, "padding-top")) {
|
|
style.paddingTop = interpretLength(value);
|
|
style.defined.paddingTop = 1;
|
|
} else if (iequalsAscii(name, "padding-bottom")) {
|
|
style.paddingBottom = interpretLength(value);
|
|
style.defined.paddingBottom = 1;
|
|
} else if (iequalsAscii(name, "padding-left")) {
|
|
style.paddingLeft = interpretLength(value);
|
|
style.defined.paddingLeft = 1;
|
|
} else if (iequalsAscii(name, "padding-right")) {
|
|
style.paddingRight = interpretLength(value);
|
|
style.defined.paddingRight = 1;
|
|
} else if (iequalsAscii(name, "padding")) {
|
|
std::string_view paddings[4];
|
|
const size_t count = collectEdgeValueTokens(value, paddings);
|
|
if (count > 0) {
|
|
style.paddingTop = interpretLength(paddings[0]);
|
|
style.paddingRight = count >= 2 ? interpretLength(paddings[1]) : style.paddingTop;
|
|
style.paddingBottom = count >= 3 ? interpretLength(paddings[2]) : style.paddingTop;
|
|
style.paddingLeft = count >= 4 ? interpretLength(paddings[3]) : style.paddingRight;
|
|
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
|
|
1;
|
|
}
|
|
} else if (iequalsAscii(name, "height")) {
|
|
CssLength len;
|
|
if (tryInterpretLength(value, len)) {
|
|
style.imageHeight = len;
|
|
style.defined.imageHeight = 1;
|
|
}
|
|
} else if (iequalsAscii(name, "width")) {
|
|
CssLength len;
|
|
if (tryInterpretLength(value, len)) {
|
|
style.imageWidth = len;
|
|
style.defined.imageWidth = 1;
|
|
}
|
|
} else if (iequalsAscii(name, "display")) {
|
|
const std::string_view displayValue = stripTrailingImportant(value);
|
|
style.display = iequalsAscii(displayValue, "none") ? CssDisplay::None : CssDisplay::Block;
|
|
style.defined.display = 1;
|
|
} else if (iequalsAscii(name, "direction")) {
|
|
const std::string_view directionValue = stripTrailingImportant(value);
|
|
if (iequalsAscii(directionValue, "rtl")) {
|
|
style.direction = CssTextDirection::Rtl;
|
|
style.defined.direction = 1;
|
|
} else if (iequalsAscii(directionValue, "ltr")) {
|
|
style.direction = CssTextDirection::Ltr;
|
|
style.defined.direction = 1;
|
|
}
|
|
} else if (iequalsAscii(name, "vertical-align")) {
|
|
if (iequalsAscii(value, "super")) {
|
|
style.verticalAlign = CssVerticalAlign::Super;
|
|
style.defined.verticalAlign = 1;
|
|
} else if (iequalsAscii(value, "sub")) {
|
|
style.verticalAlign = CssVerticalAlign::Sub;
|
|
style.defined.verticalAlign = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
CssStyle CssParser::parseDeclarations(std::string_view declBlock) {
|
|
CssStyle style;
|
|
|
|
size_t start = 0;
|
|
for (size_t i = 0; i <= declBlock.size(); ++i) {
|
|
if (i == declBlock.size() || declBlock[i] == ';') {
|
|
if (i > start) {
|
|
parseDeclarationIntoStyle(declBlock.substr(start, i - start), style);
|
|
}
|
|
start = i + 1;
|
|
}
|
|
}
|
|
|
|
return style;
|
|
}
|
|
|
|
// Rule processing
|
|
|
|
void CssParser::processRuleBlockWithStyle(std::string_view selectorGroup, const CssStyle& style) {
|
|
// Check if we've reached the rule limit before processing
|
|
if (rulesBySelector_.size() >= MAX_RULES) {
|
|
LOG_DBG("CSS", "Reached max rules limit (%zu), stopping CSS parsing", MAX_RULES);
|
|
return;
|
|
}
|
|
|
|
// Walk comma-separated selectors in place — no vector allocation. Selectors
|
|
// with unsupported syntax (combinators, attributes, pseudo, etc.) are skipped
|
|
// silently; the only heap allocation per kept selector is the std::string
|
|
// map key, which is unavoidable since the map owns its keys.
|
|
bool limitReached = false;
|
|
forEachDelimitedToken(
|
|
selectorGroup, [](char c) { return c == ','; },
|
|
[&](std::string_view sel) {
|
|
if (limitReached) return;
|
|
|
|
if (sel.size() > MAX_SELECTOR_LENGTH) {
|
|
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
|
|
return;
|
|
}
|
|
|
|
// TODO: Support richer CSS selector syntax in the future. For now we only
|
|
// handle `tag`, `.class`, or `tag.class`. Reject anything containing a
|
|
// character that introduces unsupported syntax:
|
|
// '+' adjacent sibling combinator
|
|
// '>' child combinator
|
|
// '[' attribute selector
|
|
// ':' pseudo class/element
|
|
// '#' ID selector
|
|
// '~' general sibling combinator
|
|
// '*' wildcard
|
|
// ' ' descendant combinator
|
|
// Single-pass scan via find_first_of instead of eight sequential find() calls.
|
|
constexpr std::string_view kUnsupportedSelectorChars = "+>[:#~* ";
|
|
if (sel.find_first_of(kUnsupportedSelectorChars) != std::string_view::npos) return;
|
|
|
|
// Skip if this would exceed the rule limit
|
|
if (rulesBySelector_.size() >= MAX_RULES) {
|
|
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
|
|
limitReached = true;
|
|
return;
|
|
}
|
|
|
|
// Store or merge with existing. Hash/equal are case-insensitive, so two
|
|
// selectors that differ only in ASCII case collide on insert and merge.
|
|
auto it = rulesBySelector_.find(sel);
|
|
if (it != rulesBySelector_.end()) {
|
|
it->second.applyOver(style);
|
|
} else {
|
|
rulesBySelector_.emplace(std::string(sel), style);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Main parsing entry point
|
|
|
|
bool CssParser::loadFromStream(HalFile& source) {
|
|
if (!source) {
|
|
LOG_ERR("CSS", "Cannot read from invalid file");
|
|
return false;
|
|
}
|
|
|
|
size_t totalRead = 0;
|
|
|
|
// Use stack-allocated buffers for parsing to avoid heap reallocations
|
|
StackBuffer selector;
|
|
StackBuffer declBuffer;
|
|
|
|
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;
|
|
}
|
|
|
|
if (bodyDepth == 0) {
|
|
if (selector.empty() && isCssWhitespace(c)) {
|
|
return;
|
|
}
|
|
if (c == '@' && selector.empty()) {
|
|
inAtRule = true;
|
|
atDepth = 0;
|
|
return;
|
|
}
|
|
if (c == '{') {
|
|
bodyDepth = 1;
|
|
currentStyle = CssStyle{};
|
|
declBuffer.clear();
|
|
if (selector.size() > MAX_SELECTOR_LENGTH * 4) {
|
|
skippingRule = true;
|
|
}
|
|
return;
|
|
}
|
|
selector.push_back(c);
|
|
return;
|
|
}
|
|
|
|
// bodyDepth > 0
|
|
if (c == '{') {
|
|
++bodyDepth;
|
|
return;
|
|
}
|
|
if (c == '}') {
|
|
--bodyDepth;
|
|
if (bodyDepth == 0) {
|
|
if (!skippingRule && !declBuffer.empty()) {
|
|
parseDeclarationIntoStyle(declBuffer, currentStyle);
|
|
}
|
|
if (!skippingRule) {
|
|
processRuleBlockWithStyle(selector, currentStyle);
|
|
}
|
|
selector.clear();
|
|
declBuffer.clear();
|
|
skippingRule = false;
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
if (bodyDepth > 1) {
|
|
return;
|
|
}
|
|
if (!skippingRule) {
|
|
if (c == ';') {
|
|
if (!declBuffer.empty()) {
|
|
parseDeclarationIntoStyle(declBuffer, currentStyle);
|
|
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;
|
|
}
|
|
|
|
// Style resolution
|
|
|
|
CssStyle CssParser::resolveStyle(std::string_view tagName, std::string_view 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;
|
|
|
|
// 1. Apply element-level style (lowest priority). The map's hash/equal are
|
|
// case-insensitive, so the raw tagName view can be used as the lookup key.
|
|
if (auto it = rulesBySelector_.find(tagName); it != rulesBySelector_.end()) {
|
|
result.applyOver(it->second);
|
|
}
|
|
|
|
if (classAttr.empty()) return result;
|
|
|
|
// TODO: Support combinations of classes (e.g. style on .class1.class2)
|
|
// 2. Apply class styles (medium priority). The transparent hash/equal accept
|
|
// a CompositeKey, so we never materialize the concatenation.
|
|
forEachDelimitedToken(classAttr, isCssWhitespace, [&](std::string_view cls) {
|
|
if (auto it = rulesBySelector_.find(CompositeKey{".", cls}); it != rulesBySelector_.end()) {
|
|
result.applyOver(it->second);
|
|
}
|
|
});
|
|
|
|
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
|
|
// 3. Apply element.class styles (higher priority).
|
|
forEachDelimitedToken(classAttr, isCssWhitespace, [&](std::string_view cls) {
|
|
if (auto it = rulesBySelector_.find(CompositeKey{tagName, ".", cls}); it != rulesBySelector_.end()) {
|
|
result.applyOver(it->second);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// Inline style parsing (static - doesn't need rule database)
|
|
|
|
CssStyle CssParser::parseInlineStyle(std::string_view styleValue) { return parseDeclarations(styleValue); }
|
|
|
|
// Cache serialization
|
|
|
|
// Cache file name (version is CssParser::CSS_CACHE_VERSION)
|
|
constexpr char rulesCache[] = "/css_rules.cache";
|
|
|
|
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
|
|
|
|
void CssParser::deleteCache() const {
|
|
if (hasCache()) Storage.remove((cachePath + rulesCache).c_str());
|
|
}
|
|
|
|
bool CssParser::saveToCache() const {
|
|
if (cachePath.empty()) {
|
|
return false;
|
|
}
|
|
|
|
HalFile file;
|
|
if (!Storage.openFileForWrite("CSS", cachePath + rulesCache, file)) {
|
|
return false;
|
|
}
|
|
|
|
// Write version
|
|
file.write(CssParser::CSS_CACHE_VERSION);
|
|
|
|
// Write rule count
|
|
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
|
|
file.write(reinterpret_cast<const uint8_t*>(&ruleCount), sizeof(ruleCount));
|
|
|
|
// Write each rule: selector string + CssStyle fields
|
|
for (const auto& pair : rulesBySelector_) {
|
|
// Write selector string (length-prefixed)
|
|
const auto selectorLen = static_cast<uint16_t>(pair.first.size());
|
|
file.write(reinterpret_cast<const uint8_t*>(&selectorLen), sizeof(selectorLen));
|
|
file.write(reinterpret_cast<const uint8_t*>(pair.first.data()), selectorLen);
|
|
|
|
// Write CssStyle fields (all are POD types)
|
|
const CssStyle& style = pair.second;
|
|
file.write(static_cast<uint8_t>(style.textAlign));
|
|
file.write(static_cast<uint8_t>(style.fontStyle));
|
|
file.write(static_cast<uint8_t>(style.fontWeight));
|
|
file.write(static_cast<uint8_t>(style.textDecoration));
|
|
file.write(static_cast<uint8_t>(style.direction));
|
|
|
|
// Write CssLength fields (value + unit)
|
|
auto writeLength = [&file](const CssLength& len) {
|
|
file.write(reinterpret_cast<const uint8_t*>(&len.value), sizeof(len.value));
|
|
file.write(static_cast<uint8_t>(len.unit));
|
|
};
|
|
|
|
writeLength(style.textIndent);
|
|
writeLength(style.marginTop);
|
|
writeLength(style.marginBottom);
|
|
writeLength(style.marginLeft);
|
|
writeLength(style.marginRight);
|
|
writeLength(style.paddingTop);
|
|
writeLength(style.paddingBottom);
|
|
writeLength(style.paddingLeft);
|
|
writeLength(style.paddingRight);
|
|
writeLength(style.imageHeight);
|
|
writeLength(style.imageWidth);
|
|
file.write(static_cast<uint8_t>(style.display));
|
|
file.write(static_cast<uint8_t>(style.verticalAlign));
|
|
|
|
// Write defined flags as uint32_t
|
|
uint32_t definedBits = 0;
|
|
if (style.defined.textAlign) definedBits |= 1 << 0;
|
|
if (style.defined.fontStyle) definedBits |= 1 << 1;
|
|
if (style.defined.fontWeight) definedBits |= 1 << 2;
|
|
if (style.defined.textDecoration) definedBits |= 1 << 3;
|
|
if (style.defined.textIndent) definedBits |= 1 << 4;
|
|
if (style.defined.marginTop) definedBits |= 1 << 5;
|
|
if (style.defined.marginBottom) definedBits |= 1 << 6;
|
|
if (style.defined.marginLeft) definedBits |= 1 << 7;
|
|
if (style.defined.marginRight) definedBits |= 1 << 8;
|
|
if (style.defined.paddingTop) definedBits |= 1 << 9;
|
|
if (style.defined.paddingBottom) definedBits |= 1 << 10;
|
|
if (style.defined.paddingLeft) definedBits |= 1 << 11;
|
|
if (style.defined.paddingRight) definedBits |= 1 << 12;
|
|
if (style.defined.imageHeight) definedBits |= 1 << 13;
|
|
if (style.defined.imageWidth) definedBits |= 1 << 14;
|
|
if (style.defined.display) definedBits |= 1 << 15;
|
|
if (style.defined.direction) definedBits |= 1 << 16;
|
|
if (style.defined.verticalAlign) definedBits |= 1 << 17;
|
|
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
|
|
}
|
|
|
|
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
|
|
return true;
|
|
}
|
|
|
|
bool CssParser::loadFromCache() {
|
|
if (cachePath.empty()) {
|
|
return false;
|
|
}
|
|
|
|
HalFile file;
|
|
if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) {
|
|
return false;
|
|
}
|
|
|
|
// Clear existing rules
|
|
clear();
|
|
|
|
// Read and verify version
|
|
uint8_t version = 0;
|
|
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
|
|
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
|
|
CssParser::CSS_CACHE_VERSION);
|
|
// Explicitly close() file before calling Storage.remove()
|
|
file.close();
|
|
Storage.remove((cachePath + rulesCache).c_str());
|
|
return false;
|
|
}
|
|
|
|
// Read rule count
|
|
uint16_t ruleCount = 0;
|
|
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
|
|
return false;
|
|
}
|
|
|
|
if (ruleCount > MAX_RULES) {
|
|
LOG_DBG("CSS", "Invalid cache rule count (%u > %zu)", ruleCount, MAX_RULES);
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
|
|
auto hasRemainingBytes = [&file](const size_t neededBytes) -> bool {
|
|
return static_cast<size_t>(file.available()) >= neededBytes;
|
|
};
|
|
|
|
constexpr size_t CSS_LENGTH_FIELD_COUNT = 11;
|
|
constexpr size_t CSS_LENGTH_BYTES = sizeof(float) + sizeof(uint8_t);
|
|
constexpr size_t CSS_FIXED_STYLE_BYTES =
|
|
5 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint32_t);
|
|
|
|
// Read each rule
|
|
for (uint16_t i = 0; i < ruleCount; ++i) {
|
|
// Read selector string
|
|
uint16_t selectorLen = 0;
|
|
if (!hasRemainingBytes(sizeof(selectorLen))) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
|
|
if (selectorLen == 0 || selectorLen > MAX_SELECTOR_LENGTH || !hasRemainingBytes(selectorLen)) {
|
|
LOG_DBG("CSS", "Invalid selector length in cache: %u", selectorLen);
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
|
|
std::string selector;
|
|
selector.resize(selectorLen);
|
|
if (file.read(&selector[0], selectorLen) != selectorLen) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
|
|
if (!hasRemainingBytes(CSS_FIXED_STYLE_BYTES)) {
|
|
LOG_DBG("CSS", "Truncated CSS cache while reading style payload");
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
|
|
// Read CssStyle fields
|
|
CssStyle style;
|
|
uint8_t enumVal;
|
|
|
|
if (file.read(&enumVal, 1) != 1) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.textAlign = static_cast<CssTextAlign>(enumVal);
|
|
|
|
if (file.read(&enumVal, 1) != 1) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.fontStyle = static_cast<CssFontStyle>(enumVal);
|
|
|
|
if (file.read(&enumVal, 1) != 1) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.fontWeight = static_cast<CssFontWeight>(enumVal);
|
|
|
|
if (file.read(&enumVal, 1) != 1) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
|
|
|
if (file.read(&enumVal, 1) != 1) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.direction = static_cast<CssTextDirection>(enumVal);
|
|
|
|
// Read CssLength fields
|
|
auto readLength = [&file](CssLength& len) -> bool {
|
|
if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) {
|
|
return false;
|
|
}
|
|
uint8_t unitVal;
|
|
if (file.read(&unitVal, 1) != 1) {
|
|
return false;
|
|
}
|
|
len.unit = static_cast<CssUnit>(unitVal);
|
|
return true;
|
|
};
|
|
|
|
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
|
|
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
|
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) ||
|
|
!readLength(style.imageHeight) || !readLength(style.imageWidth)) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
|
|
// Read display value
|
|
uint8_t displayVal;
|
|
if (file.read(&displayVal, 1) != 1) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.display = static_cast<CssDisplay>(displayVal);
|
|
|
|
// Read verticalAlign value
|
|
uint8_t verticalAlignVal;
|
|
if (file.read(&verticalAlignVal, 1) != 1) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.verticalAlign = static_cast<CssVerticalAlign>(verticalAlignVal);
|
|
|
|
// Read defined flags
|
|
uint32_t definedBits = 0;
|
|
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
|
|
rulesBySelector_.clear();
|
|
return false;
|
|
}
|
|
style.defined.textAlign = (definedBits & 1 << 0) != 0;
|
|
style.defined.fontStyle = (definedBits & 1 << 1) != 0;
|
|
style.defined.fontWeight = (definedBits & 1 << 2) != 0;
|
|
style.defined.textDecoration = (definedBits & 1 << 3) != 0;
|
|
style.defined.textIndent = (definedBits & 1 << 4) != 0;
|
|
style.defined.marginTop = (definedBits & 1 << 5) != 0;
|
|
style.defined.marginBottom = (definedBits & 1 << 6) != 0;
|
|
style.defined.marginLeft = (definedBits & 1 << 7) != 0;
|
|
style.defined.marginRight = (definedBits & 1 << 8) != 0;
|
|
style.defined.paddingTop = (definedBits & 1 << 9) != 0;
|
|
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
|
|
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
|
|
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
|
|
style.defined.imageHeight = (definedBits & 1 << 13) != 0;
|
|
style.defined.imageWidth = (definedBits & 1 << 14) != 0;
|
|
style.defined.display = (definedBits & 1 << 15) != 0;
|
|
style.defined.direction = (definedBits & 1 << 16) != 0;
|
|
style.defined.verticalAlign = (definedBits & 1 << 17) != 0;
|
|
|
|
rulesBySelector_[selector] = style;
|
|
}
|
|
|
|
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
|
|
return true;
|
|
}
|