diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index cd023c21..9d0e3252 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include 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(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 +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(14695981039346656037ULL) : static_cast(2166136261U); +constexpr size_t FNV_PRIME = + sizeof(size_t) == 8 ? static_cast(1099511628211ULL) : static_cast(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 +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(std::tolower(static_cast(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(std::tolower(static_cast(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 CssParser::splitOnChar(const std::string& s, const char delimiter) { - std::vector 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 CssParser::splitWhitespace(const std::string& s) { - std::vector 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 diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index f5ead1f7..45346c71 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -2,7 +2,9 @@ #include +#include #include +#include #include #include #include @@ -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 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 pieces; + CompositeKey(std::initializer_list 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 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 splitOnChar(const std::string& s, char delimiter); - static std::vector splitWhitespace(const std::string& s); + static bool tryInterpretLength(std::string_view val, CssLength& out); };