perf: Minimize string allocations in CSS parsing (#2263)
## 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**_
This commit is contained in:
+288
-291
@@ -6,6 +6,8 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
@@ -29,9 +31,7 @@ struct StackBuffer {
|
||||
|
||||
// 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); }
|
||||
operator std::string_view() const noexcept { return view(); }
|
||||
};
|
||||
|
||||
// Buffer size for reading CSS files
|
||||
@@ -50,7 +50,87 @@ constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024;
|
||||
constexpr size_t MAX_SELECTOR_LENGTH = 256;
|
||||
|
||||
// Check if character is CSS whitespace
|
||||
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
|
||||
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";
|
||||
@@ -64,7 +144,7 @@ std::string_view stripTrailingImportant(std::string_view value) {
|
||||
}
|
||||
|
||||
const size_t suffixPos = value.size() - IMPORTANT.size();
|
||||
if (value.substr(suffixPos) != IMPORTANT) {
|
||||
if (!iequalsAscii(value.substr(suffixPos), IMPORTANT)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -77,178 +157,143 @@ std::string_view stripTrailingImportant(std::string_view value) {
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// String utilities implementation
|
||||
// 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.
|
||||
|
||||
std::string CssParser::normalized(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size());
|
||||
|
||||
bool inSpace = true; // Start true to skip leading space
|
||||
for (const char c : s) {
|
||||
if (isCssWhitespace(c)) {
|
||||
if (!inSpace) {
|
||||
result.push_back(' ');
|
||||
inSpace = true;
|
||||
}
|
||||
} else {
|
||||
result.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
|
||||
inSpace = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing space
|
||||
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
|
||||
result.pop_back();
|
||||
}
|
||||
return result;
|
||||
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;
|
||||
}
|
||||
|
||||
void CssParser::normalizedInto(const std::string& s, std::string& out) {
|
||||
out.clear();
|
||||
out.reserve(s.size());
|
||||
size_t CssParser::SvHash::operator()(const std::string& s) const noexcept { return operator()(std::string_view(s)); }
|
||||
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
|
||||
std::vector<std::string> parts;
|
||||
size_t start = 0;
|
||||
|
||||
for (size_t i = 0; i <= s.size(); ++i) {
|
||||
if (i == s.size() || s[i] == delimiter) {
|
||||
std::string part = s.substr(start, i - start);
|
||||
std::string trimmed = normalized(part);
|
||||
if (!trimmed.empty()) {
|
||||
parts.push_back(trimmed);
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
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 parts;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::string> CssParser::splitWhitespace(const std::string& s) {
|
||||
std::vector<std::string> parts;
|
||||
size_t start = 0;
|
||||
bool inWord = false;
|
||||
bool CssParser::SvEqual::operator()(const std::string& a, std::string_view b) const noexcept {
|
||||
return operator()(std::string_view(a), b);
|
||||
}
|
||||
|
||||
for (size_t i = 0; i <= s.size(); ++i) {
|
||||
const bool isSpace = i == s.size() || isCssWhitespace(s[i]);
|
||||
if (isSpace && inWord) {
|
||||
parts.push_back(s.substr(start, i - start));
|
||||
inWord = false;
|
||||
} else if (!isSpace && !inWord) {
|
||||
start = i;
|
||||
inWord = true;
|
||||
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 parts;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CssParser::SvEqual::operator()(std::string_view sv, CompositeKey k) const noexcept { return operator()(k, sv); }
|
||||
|
||||
// Property value interpreters
|
||||
|
||||
CssTextAlign CssParser::interpretAlignment(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
CssTextAlign CssParser::interpretAlignment(std::string_view val) {
|
||||
val = trimCssWhitespace(val);
|
||||
|
||||
if (v == "left" || v == "start") return CssTextAlign::Left;
|
||||
if (v == "right" || v == "end") return CssTextAlign::Right;
|
||||
if (v == "center") return CssTextAlign::Center;
|
||||
if (v == "justify") return CssTextAlign::Justify;
|
||||
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(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
CssFontStyle CssParser::interpretFontStyle(std::string_view val) {
|
||||
val = trimCssWhitespace(val);
|
||||
|
||||
if (v == "italic" || v == "oblique") return CssFontStyle::Italic;
|
||||
if (iequalsAscii(val, "italic") || iequalsAscii(val, "oblique")) return CssFontStyle::Italic;
|
||||
return CssFontStyle::Normal;
|
||||
}
|
||||
|
||||
CssFontWeight CssParser::interpretFontWeight(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
|
||||
val = trimCssWhitespace(val);
|
||||
|
||||
// Named values
|
||||
if (v == "bold" || v == "bolder") return CssFontWeight::Bold;
|
||||
if (v == "normal" || v == "lighter") return CssFontWeight::Normal;
|
||||
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)
|
||||
char* endPtr = nullptr;
|
||||
const long numericWeight = std::strtol(v.c_str(), &endPtr, 10);
|
||||
|
||||
// If we parsed a number and consumed the whole string
|
||||
if (endPtr != v.c_str() && *endPtr == '\0') {
|
||||
long numericWeight = 0;
|
||||
if (tryParseNumber(val, numericWeight)) {
|
||||
return numericWeight >= 700 ? CssFontWeight::Bold : CssFontWeight::Normal;
|
||||
}
|
||||
|
||||
return CssFontWeight::Normal;
|
||||
}
|
||||
|
||||
CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
|
||||
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
||||
// text-decoration can have multiple space-separated values
|
||||
if (v.find("underline") != std::string::npos) {
|
||||
if (icontainsAscii(val, "underline")) {
|
||||
return CssTextDecoration::Underline;
|
||||
}
|
||||
return CssTextDecoration::None;
|
||||
}
|
||||
|
||||
CssLength CssParser::interpretLength(const std::string& val) {
|
||||
CssLength CssParser::interpretLength(std::string_view val) {
|
||||
CssLength result;
|
||||
tryInterpretLength(val, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) {
|
||||
bool CssParser::tryInterpretLength(std::string_view val, CssLength& out) {
|
||||
val = trimCssWhitespace(val);
|
||||
if (val.empty()) {
|
||||
out = CssLength{};
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t unitStart = v.size();
|
||||
for (size_t i = 0; i < v.size(); ++i) {
|
||||
const char c = v[i];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string numPart = v.substr(0, unitStart);
|
||||
const std::string unitPart = v.substr(unitStart);
|
||||
|
||||
char* endPtr = nullptr;
|
||||
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||
if (endPtr == numPart.c_str()) {
|
||||
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 (unitPart == "em") {
|
||||
if (iequalsAscii(unitPart, "em")) {
|
||||
unit = CssUnit::Em;
|
||||
} else if (unitPart == "rem") {
|
||||
} else if (iequalsAscii(unitPart, "rem")) {
|
||||
unit = CssUnit::Rem;
|
||||
} else if (unitPart == "pt") {
|
||||
} else if (iequalsAscii(unitPart, "pt")) {
|
||||
unit = CssUnit::Points;
|
||||
} else if (unitPart == "%") {
|
||||
unit = CssUnit::Percent;
|
||||
@@ -260,125 +305,119 @@ bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
|
||||
|
||||
// Declaration parsing
|
||||
|
||||
void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
|
||||
std::string& propValueBuf) {
|
||||
void CssParser::parseDeclarationIntoStyle(std::string_view decl, CssStyle& style) {
|
||||
const size_t colonPos = decl.find(':');
|
||||
if (colonPos == std::string::npos || colonPos == 0) return;
|
||||
if (colonPos == std::string_view::npos || colonPos == 0) return;
|
||||
|
||||
normalizedInto(decl.substr(0, colonPos), propNameBuf);
|
||||
normalizedInto(decl.substr(colonPos + 1), propValueBuf);
|
||||
const std::string_view name = trimCssWhitespace(decl.substr(0, colonPos));
|
||||
const std::string_view value = trimCssWhitespace(decl.substr(colonPos + 1));
|
||||
|
||||
if (propNameBuf.empty() || propValueBuf.empty()) return;
|
||||
if (name.empty() || value.empty()) return;
|
||||
|
||||
if (propNameBuf == "text-align") {
|
||||
style.textAlign = interpretAlignment(propValueBuf);
|
||||
if (iequalsAscii(name, "text-align")) {
|
||||
style.textAlign = interpretAlignment(value);
|
||||
style.defined.textAlign = 1;
|
||||
} else if (propNameBuf == "font-style") {
|
||||
style.fontStyle = interpretFontStyle(propValueBuf);
|
||||
} else if (iequalsAscii(name, "font-style")) {
|
||||
style.fontStyle = interpretFontStyle(value);
|
||||
style.defined.fontStyle = 1;
|
||||
} else if (propNameBuf == "font-weight") {
|
||||
style.fontWeight = interpretFontWeight(propValueBuf);
|
||||
} else if (iequalsAscii(name, "font-weight")) {
|
||||
style.fontWeight = interpretFontWeight(value);
|
||||
style.defined.fontWeight = 1;
|
||||
} else if (propNameBuf == "text-decoration" || propNameBuf == "text-decoration-line") {
|
||||
style.textDecoration = interpretDecoration(propValueBuf);
|
||||
} else if (iequalsAscii(name, "text-decoration") || iequalsAscii(name, "text-decoration-line")) {
|
||||
style.textDecoration = interpretDecoration(value);
|
||||
style.defined.textDecoration = 1;
|
||||
} else if (propNameBuf == "text-indent") {
|
||||
style.textIndent = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "text-indent")) {
|
||||
style.textIndent = interpretLength(value);
|
||||
style.defined.textIndent = 1;
|
||||
} else if (propNameBuf == "margin-top") {
|
||||
style.marginTop = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "margin-top")) {
|
||||
style.marginTop = interpretLength(value);
|
||||
style.defined.marginTop = 1;
|
||||
} else if (propNameBuf == "margin-bottom") {
|
||||
style.marginBottom = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "margin-bottom")) {
|
||||
style.marginBottom = interpretLength(value);
|
||||
style.defined.marginBottom = 1;
|
||||
} else if (propNameBuf == "margin-left") {
|
||||
style.marginLeft = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "margin-left")) {
|
||||
style.marginLeft = interpretLength(value);
|
||||
style.defined.marginLeft = 1;
|
||||
} else if (propNameBuf == "margin-right") {
|
||||
style.marginRight = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "margin-right")) {
|
||||
style.marginRight = interpretLength(value);
|
||||
style.defined.marginRight = 1;
|
||||
} else if (propNameBuf == "margin") {
|
||||
const auto values = splitWhitespace(propValueBuf);
|
||||
if (!values.empty()) {
|
||||
style.marginTop = interpretLength(values[0]);
|
||||
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
|
||||
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
|
||||
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
|
||||
} 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 (propNameBuf == "padding-top") {
|
||||
style.paddingTop = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "padding-top")) {
|
||||
style.paddingTop = interpretLength(value);
|
||||
style.defined.paddingTop = 1;
|
||||
} else if (propNameBuf == "padding-bottom") {
|
||||
style.paddingBottom = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "padding-bottom")) {
|
||||
style.paddingBottom = interpretLength(value);
|
||||
style.defined.paddingBottom = 1;
|
||||
} else if (propNameBuf == "padding-left") {
|
||||
style.paddingLeft = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "padding-left")) {
|
||||
style.paddingLeft = interpretLength(value);
|
||||
style.defined.paddingLeft = 1;
|
||||
} else if (propNameBuf == "padding-right") {
|
||||
style.paddingRight = interpretLength(propValueBuf);
|
||||
} else if (iequalsAscii(name, "padding-right")) {
|
||||
style.paddingRight = interpretLength(value);
|
||||
style.defined.paddingRight = 1;
|
||||
} else if (propNameBuf == "padding") {
|
||||
const auto values = splitWhitespace(propValueBuf);
|
||||
if (!values.empty()) {
|
||||
style.paddingTop = interpretLength(values[0]);
|
||||
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
|
||||
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
|
||||
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
|
||||
} 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 (propNameBuf == "height") {
|
||||
} else if (iequalsAscii(name, "height")) {
|
||||
CssLength len;
|
||||
if (tryInterpretLength(propValueBuf, len)) {
|
||||
if (tryInterpretLength(value, len)) {
|
||||
style.imageHeight = len;
|
||||
style.defined.imageHeight = 1;
|
||||
}
|
||||
} else if (propNameBuf == "width") {
|
||||
} else if (iequalsAscii(name, "width")) {
|
||||
CssLength len;
|
||||
if (tryInterpretLength(propValueBuf, len)) {
|
||||
if (tryInterpretLength(value, len)) {
|
||||
style.imageWidth = len;
|
||||
style.defined.imageWidth = 1;
|
||||
}
|
||||
} else if (propNameBuf == "display") {
|
||||
const std::string_view displayValue = stripTrailingImportant(propValueBuf);
|
||||
style.display = (displayValue == "none") ? CssDisplay::None : CssDisplay::Block;
|
||||
} 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 (propNameBuf == "direction") {
|
||||
const std::string_view directionValue = stripTrailingImportant(propValueBuf);
|
||||
if (directionValue == "rtl") {
|
||||
} 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 (directionValue == "ltr") {
|
||||
} else if (iequalsAscii(directionValue, "ltr")) {
|
||||
style.direction = CssTextDirection::Ltr;
|
||||
style.defined.direction = 1;
|
||||
}
|
||||
} else if (propNameBuf == "vertical-align") {
|
||||
const std::string v = normalized(propValueBuf);
|
||||
if (v == "super") {
|
||||
} else if (iequalsAscii(name, "vertical-align")) {
|
||||
if (iequalsAscii(value, "super")) {
|
||||
style.verticalAlign = CssVerticalAlign::Super;
|
||||
style.defined.verticalAlign = 1;
|
||||
} else if (v == "sub") {
|
||||
} else if (iequalsAscii(value, "sub")) {
|
||||
style.verticalAlign = CssVerticalAlign::Sub;
|
||||
style.defined.verticalAlign = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||
CssStyle CssParser::parseDeclarations(std::string_view 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);
|
||||
}
|
||||
parseDeclarationIntoStyle(declBlock.substr(start, i - start), style);
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
@@ -389,91 +428,59 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||
|
||||
// Rule processing
|
||||
|
||||
void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Handle comma-separated selectors
|
||||
const auto selectors = splitOnChar(selectorGroup, ',');
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
if (sel.size() > MAX_SELECTOR_LENGTH) {
|
||||
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize the selector
|
||||
std::string key = normalized(sel);
|
||||
if (key.empty()) continue;
|
||||
// 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;
|
||||
|
||||
// TODO: Consider adding support for sibling css selectors in the future
|
||||
// Ensure no + in selector as we don't support adjacent CSS selectors for now
|
||||
if (key.find('+') != std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// TODO: Consider adding support for direct nested css selectors in the future
|
||||
// Ensure no > in selector as we don't support nested CSS selectors for now
|
||||
if (key.find('>') != std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Consider adding support for attribute css selectors in the future
|
||||
// Ensure no [ in selector as we don't support attribute CSS selectors for now
|
||||
if (key.find('[') != std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Consider adding support for pseudo selectors in the future
|
||||
// Ensure no : in selector as we don't support pseudo CSS selectors for now
|
||||
if (key.find(':') != std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Consider adding support for ID css selectors in the future
|
||||
// Ensure no # in selector as we don't support ID CSS selectors for now
|
||||
if (key.find('#') != std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Consider adding support for general sibling combinator selectors in the future
|
||||
// Ensure no ~ in selector as we don't support general sibling combinator CSS selectors for now
|
||||
if (key.find('~') != std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Consider adding support for wildcard css selectors in the future
|
||||
// Ensure no * in selector as we don't support wildcard CSS selectors for now
|
||||
if (key.find('*') != std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: Add support for more complex selectors in the future
|
||||
// At the moment, we only ever check for `tag`, `tag.class1` or `.class1`
|
||||
// If the selector has whitespace in it, then it's either a CSS selector for a descendant element (e.g. `tag1 tag2`)
|
||||
// or some other slightly more advanced CSS selector which we don't support yet
|
||||
if (key.find(' ') != std::string_view::npos) {
|
||||
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
|
||||
auto it = rulesBySelector_.find(key);
|
||||
if (it != rulesBySelector_.end()) {
|
||||
it->second.applyOver(style);
|
||||
} else {
|
||||
rulesBySelector_[key] = style;
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -489,9 +496,6 @@ bool CssParser::loadFromStream(HalFile& source) {
|
||||
// Use stack-allocated buffers for parsing to avoid heap reallocations
|
||||
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;
|
||||
@@ -548,10 +552,10 @@ bool CssParser::loadFromStream(HalFile& source) {
|
||||
--bodyDepth;
|
||||
if (bodyDepth == 0) {
|
||||
if (!skippingRule && !declBuffer.empty()) {
|
||||
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
|
||||
parseDeclarationIntoStyle(declBuffer, currentStyle);
|
||||
}
|
||||
if (!skippingRule) {
|
||||
processRuleBlockWithStyle(selector.str(), currentStyle);
|
||||
processRuleBlockWithStyle(selector, currentStyle);
|
||||
}
|
||||
selector.clear();
|
||||
declBuffer.clear();
|
||||
@@ -566,7 +570,7 @@ bool CssParser::loadFromStream(HalFile& source) {
|
||||
if (!skippingRule) {
|
||||
if (c == ';') {
|
||||
if (!declBuffer.empty()) {
|
||||
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
|
||||
parseDeclarationIntoStyle(declBuffer, currentStyle);
|
||||
declBuffer.clear();
|
||||
}
|
||||
} else {
|
||||
@@ -626,7 +630,7 @@ bool CssParser::loadFromStream(HalFile& source) {
|
||||
|
||||
// Style resolution
|
||||
|
||||
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
|
||||
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) {
|
||||
@@ -636,47 +640,40 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
||||
}
|
||||
return CssStyle{};
|
||||
}
|
||||
CssStyle result;
|
||||
const std::string tag = normalized(tagName);
|
||||
|
||||
// 1. Apply element-level style (lowest priority)
|
||||
const auto tagIt = rulesBySelector_.find(tag);
|
||||
if (tagIt != rulesBySelector_.end()) {
|
||||
result.applyOver(tagIt->second);
|
||||
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)
|
||||
if (!classAttr.empty()) {
|
||||
const auto classes = splitWhitespace(classAttr);
|
||||
|
||||
for (const auto& cls : classes) {
|
||||
std::string classKey = "." + normalized(cls);
|
||||
|
||||
auto classIt = rulesBySelector_.find(classKey);
|
||||
if (classIt != rulesBySelector_.end()) {
|
||||
result.applyOver(classIt->second);
|
||||
}
|
||||
// 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)
|
||||
for (const auto& cls : classes) {
|
||||
std::string combinedKey = tag + "." + normalized(cls);
|
||||
|
||||
auto combinedIt = rulesBySelector_.find(combinedKey);
|
||||
if (combinedIt != rulesBySelector_.end()) {
|
||||
result.applyOver(combinedIt->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(const std::string& styleValue) { return parseDeclarations(styleValue); }
|
||||
CssStyle CssParser::parseInlineStyle(std::string_view styleValue) { return parseDeclarations(styleValue); }
|
||||
|
||||
// Cache serialization
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -56,14 +58,14 @@ class CssParser {
|
||||
* @param classAttr The class attribute value (may contain multiple space-separated classes)
|
||||
* @return Combined style with all applicable rules merged
|
||||
*/
|
||||
[[nodiscard]] CssStyle resolveStyle(const std::string& tagName, const std::string& classAttr) const;
|
||||
[[nodiscard]] CssStyle resolveStyle(std::string_view tagName, std::string_view classAttr) const;
|
||||
|
||||
/**
|
||||
* Parse an inline style attribute string.
|
||||
* @param styleValue The value of a style="" attribute
|
||||
* @return Parsed style properties
|
||||
*/
|
||||
[[nodiscard]] static CssStyle parseInlineStyle(const std::string& styleValue);
|
||||
[[nodiscard]] static CssStyle parseInlineStyle(std::string_view styleValue);
|
||||
|
||||
/**
|
||||
* Check if any rules have been loaded
|
||||
@@ -104,29 +106,53 @@ class CssParser {
|
||||
bool loadFromCache();
|
||||
|
||||
private:
|
||||
// Storage: maps normalized selector -> style properties
|
||||
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
||||
// Lookup key for a multi-piece selector. The pieces are hashed and compared
|
||||
// as if concatenated, so callers can look up composite keys without
|
||||
// materializing the concatenation in a scratch buffer. Constructed from a
|
||||
// braced list of any arity, e.g. `CompositeKey{tagName, ".", cls}` or
|
||||
// `CompositeKey{".", cls}`. The initializer_list's backing array lives for
|
||||
// the full expression, which covers the lifetime of the find() call.
|
||||
struct CompositeKey {
|
||||
std::initializer_list<std::string_view> pieces;
|
||||
CompositeKey(std::initializer_list<std::string_view> p) noexcept : pieces(p) {}
|
||||
};
|
||||
|
||||
// ASCII-case-insensitive transparent hash/equal. Stored selectors and lookup
|
||||
// keys are compared without regard to case, so callers may insert and look up
|
||||
// using whatever case the CSS source or HTML element name happens to use.
|
||||
// Bodies live in CssParser.cpp so they can share the file-local asciiToLower.
|
||||
struct SvHash {
|
||||
using is_transparent = void;
|
||||
size_t operator()(std::string_view sv) const noexcept;
|
||||
size_t operator()(const std::string& s) const noexcept;
|
||||
size_t operator()(CompositeKey k) const noexcept;
|
||||
};
|
||||
struct SvEqual {
|
||||
using is_transparent = void;
|
||||
bool operator()(std::string_view a, std::string_view b) const noexcept;
|
||||
bool operator()(const std::string& a, std::string_view b) const noexcept;
|
||||
bool operator()(std::string_view a, const std::string& b) const noexcept;
|
||||
bool operator()(const std::string& a, const std::string& b) const noexcept;
|
||||
bool operator()(CompositeKey a, std::string_view b) const noexcept;
|
||||
bool operator()(std::string_view a, CompositeKey b) const noexcept;
|
||||
};
|
||||
|
||||
// Storage: maps selector -> style properties. Hash/equal are case-insensitive.
|
||||
std::unordered_map<std::string, CssStyle, SvHash, SvEqual> rulesBySelector_;
|
||||
|
||||
std::string cachePath;
|
||||
|
||||
// Internal parsing helpers
|
||||
void processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style);
|
||||
static CssStyle parseDeclarations(const std::string& declBlock);
|
||||
static void parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
|
||||
std::string& propValueBuf);
|
||||
void processRuleBlockWithStyle(std::string_view selectorGroup, const CssStyle& style);
|
||||
static CssStyle parseDeclarations(std::string_view declBlock);
|
||||
static void parseDeclarationIntoStyle(std::string_view decl, CssStyle& style);
|
||||
|
||||
// Individual property value parsers
|
||||
static CssTextAlign interpretAlignment(const std::string& val);
|
||||
static CssFontStyle interpretFontStyle(const std::string& val);
|
||||
static CssFontWeight interpretFontWeight(const std::string& val);
|
||||
static CssTextDecoration interpretDecoration(const std::string& val);
|
||||
static CssLength interpretLength(const std::string& val);
|
||||
static CssTextAlign interpretAlignment(std::string_view val);
|
||||
static CssFontStyle interpretFontStyle(std::string_view val);
|
||||
static CssFontWeight interpretFontWeight(std::string_view val);
|
||||
static CssTextDecoration interpretDecoration(std::string_view val);
|
||||
static CssLength interpretLength(std::string_view val);
|
||||
/** Returns true only when a numeric length was parsed (e.g. 2em, 50%). False for auto/inherit/initial. */
|
||||
static bool tryInterpretLength(const std::string& val, CssLength& out);
|
||||
|
||||
// String utilities
|
||||
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> splitWhitespace(const std::string& s);
|
||||
static bool tryInterpretLength(std::string_view val, CssLength& out);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user