Loading src/css.cpp +137 −40 Original line number Diff line number Diff line Loading @@ -103,17 +103,49 @@ namespace { return result; } // Whether `compound`'s tag/classes/id are ALL present on the given // candidate tag/classes/id -- every class listed must be present, not // just one. Shared by compoundMatches (the target element, which adds // its own "bare tag after combinator" guard on top -- see there) and // ancestorChainSatisfies (a specific ancestor frame, which needs no such // guard: a lone tag requirement checked against one concrete candidate // is a meaningful constraint, not the blanket-match hazard a lone tag // with no ancestor to check it against would be). A compound with // nothing specified at all (tag/classes/id all empty -- e.g. it was // purely an attribute selector or pseudo-class, see // stripAttributeSelectors/hasUnsupportedSelectorSyntax) trivially // matches anything: this helper has no opinion on whether an empty // compound should count as a real requirement, that's each caller's // call (compoundMatches rejects it via specifiedSomething, // ancestorChainSatisfies treats it as vacuously satisfied). bool compoundPartsMatch(const CompoundParts &compound, const std::string &tag, const std::vector<std::string> &classes, const std::string &id) { bool tagOk = compound.tag.empty() || compound.tag == tag; bool idOk = compound.id.empty() || compound.id == id; bool classesOk = true; for (const auto &cls : compound.classes) { if (std::find(classes.begin(), classes.end(), cls) == classes.end()) { classesOk = false; break; } } return tagOk && idOk && classesOk; } // A selector matches only if EVERY part it specifies is present on this // element -- in particular every class listed, not just one. A bare tag // left over after stripping a combinator's ancestor part (e.g. // "[data-atom=header]>div" reduces to bare "div") can't be verified -- // this matcher only ever sees the CURRENT element's own tag/class/id, // never its ancestor chain -- so treating it as a match would apply the // rule to literally every element of that tag in the whole document // (confirmed against a real scraped page where exactly this pattern // matched ~24,000 times and blew up an HTML-import's output to 36MB). A // class/id qualifier alongside the tag (e.g. "div.foo") is still // specific enough to check. // "[data-atom=header]>div" reduces to bare "div") can't be verified when // no ancestor chain is available to check it against -- treating it as a // match would apply the rule to literally every element of that tag in // the whole document (confirmed against a real scraped page where // exactly this pattern matched ~24,000 times and blew up an HTML-import's // output to 36MB). A class/id qualifier alongside the tag (e.g. // "div.foo") is still specific enough to check, and this guard only ever // applies to the trailing/target compound -- ancestorChainSatisfies // checks leading compounds against a real candidate frame instead, so a // bare-tag ancestor requirement is safe there. bool compoundMatches(const CompoundParts &compound, bool hadCombinator, const std::string &tag, const std::vector<std::string> &classes, const std::string &id) Loading @@ -123,34 +155,68 @@ namespace { bool specifiedSomething = !compound.tag.empty() || !compound.classes.empty() || !compound.id.empty(); if (bareTagAfterCombinator || !specifiedSomething) return false; bool tagOk = compound.tag.empty() || compound.tag == tag; bool idOk = compound.id.empty() || compound.id == id; bool classesOk = true; for (const auto &cls : compound.classes) { if (std::find(classes.begin(), classes.end(), cls) == classes.end()) { classesOk = false; return compoundPartsMatch(compound, tag, classes, id); } // Splits a single (already comma-branch-isolated) selector into its // combinator-separated compounds, left-to-right / outermost-ancestor- // first -- e.g. ".a .b > .c" -> [".a", ".b", ".c"]. Combinator type // (space/">"/"+"/"~") is discarded, same simplification this matcher // always made even when it only ever looked at the trailing compound: // it never distinguished child from descendant/sibling combinators. // Verifying ancestors approximately -- present somewhere in the chain, // in left-to-right order, not necessarily adjacent -- is still far more // correct than not verifying them at all (see ancestorChainSatisfies). std::vector<std::string> splitCombinatorChain(const std::string &selector) { std::vector<std::string> parts; std::string cur; for (char c : selector) { if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '>' || c == '+' || c == '~') { if (!cur.empty()) { parts.push_back(cur); cur.clear(); } } else { cur += c; } } if (!cur.empty()) parts.push_back(cur); return parts; } // Whether every compound in `ancestorCompounds` (left-to-right / // outermost-first -- see splitCombinatorChain) can be matched, in that // order, against some frame in `ancestors` (also outermost-first, // closest/immediate-parent last -- see AncestorFrame). Each compound // consumes the ancestor it matched and everything closer to the target // than it, so a later compound can never be satisfied by an ancestor // farther out than one an earlier compound already matched -- // approximating real left-to-right descendant-selector semantics // without distinguishing combinator type, requiring adjacency, or // computing true specificity. An empty compound (unsafe syntax reduced // to nothing, see splitCombinatorChain's callers) is vacuously satisfied // without consuming an ancestor slot -- an unverifiable requirement // shouldn't by itself invalidate an otherwise plausible match, same // "not proven to not match" philosophy as the rest of this file. bool ancestorChainSatisfies(const std::vector<CompoundParts> &ancestorCompounds, const std::vector<libhtmlpp::AncestorFrame> &ancestors) { size_t idx = 0; for (const auto &compound : ancestorCompounds) { bool specifiedSomething = !compound.tag.empty() || !compound.classes.empty() || !compound.id.empty(); if (!specifiedSomething) continue; bool found = false; while (idx < ancestors.size()) { const libhtmlpp::AncestorFrame &frame = ancestors[idx]; ++idx; if (compoundPartsMatch(compound, frame.tag, frame.classes, frame.id)) { found = true; break; } } return tagOk && idOk && classesOk; } // Strips a trailing combinator (space/">"/"+"/"~") from `singleSel`, // returning the trailing compound selector text and whether a // combinator was actually present. Only the trailing compound is ever // checked -- this matcher never verifies ancestor/parent context. std::string stripCombinator(const std::string &singleSel, bool &hadCombinator) { std::string matchSel = singleSel; hadCombinator = false; size_t lastSep = matchSel.find_last_of(" >+~"); if (lastSep != std::string::npos) { size_t mstart = matchSel.find_first_not_of(" >+~", lastSep); if (mstart != std::string::npos) { matchSel = matchSel.substr(mstart); hadCombinator = true; if (!found) return false; } } return matchSel; return true; } // Selector syntax this matcher can't safely evaluate -- pseudo- Loading Loading @@ -685,7 +751,8 @@ libhtmlpp::CSSDeclaration libhtmlpp::CSSStyleSheet::parseInlineStyle(const std:: bool libhtmlpp::CSSStyleSheet::approximateSelectorMatch(const std::string &selector, const std::string &tag, const std::vector<std::string> &classes, const std::string &id) const std::string &id, const std::vector<AncestorFrame> *ancestors) { std::string tagLower = tag; std::transform(tagLower.begin(), tagLower.end(), tagLower.begin(), Loading @@ -699,13 +766,26 @@ bool libhtmlpp::CSSStyleSheet::approximateSelectorMatch(const std::string &selec size_t end = singleSel.find_last_not_of(" \t\n\r"); singleSel = singleSel.substr(start, end - start + 1); bool hadCombinator = false; std::string matchSel = stripCombinator(singleSel, hadCombinator); matchSel = stripAttributeSelectors(matchSel); std::vector<std::string> chain = splitCombinatorChain(singleSel); if (chain.empty()) continue; bool hadCombinator = chain.size() > 1; std::string matchSel = stripAttributeSelectors(chain.back()); if (hasUnsupportedSelectorSyntax(matchSel)) continue; CompoundParts compound = parseCompoundSelector(matchSel); if (compoundMatches(compound, hadCombinator, tagLower, classes, id)) return true; if (!compoundMatches(compound, hadCombinator, tagLower, classes, id)) continue; if (ancestors && chain.size() > 1) { std::vector<CompoundParts> ancestorCompounds; for (size_t i = 0; i + 1 < chain.size(); ++i) { std::string aSel = stripAttributeSelectors(chain[i]); ancestorCompounds.push_back( hasUnsupportedSelectorSyntax(aSel) ? CompoundParts{} : parseCompoundSelector(aSel)); } if (!ancestorChainSatisfies(ancestorCompounds, *ancestors)) continue; } return true; } return false; } Loading Loading @@ -792,10 +872,11 @@ void libhtmlpp::CSSStyleSheet::_rebuildCompoundCache() const { branch.atWrapper = atWrapper; branch.trimmedSelector = singleSel; bool hadCombinator = false; std::string matchSel = stripCombinator(singleSel, hadCombinator); matchSel = stripAttributeSelectors(matchSel); if (hasUnsupportedSelectorSyntax(matchSel)) { std::vector<std::string> chain = splitCombinatorChain(singleSel); bool hadCombinator = chain.size() > 1; std::string matchSel = chain.empty() ? std::string() : stripAttributeSelectors(chain.back()); if (chain.empty() || hasUnsupportedSelectorSyntax(matchSel)) { branch.skip = true; } else { CompoundParts compound = parseCompoundSelector(matchSel); Loading @@ -804,6 +885,13 @@ void libhtmlpp::CSSStyleSheet::_rebuildCompoundCache() const { branch.classes = compound.classes; branch.id = compound.id; branch.hadCombinator = hadCombinator; for (size_t i = 0; i + 1 < chain.size(); ++i) { std::string aSel = stripAttributeSelectors(chain[i]); CompoundParts aCompound = hasUnsupportedSelectorSyntax(aSel) ? CompoundParts{} : parseCompoundSelector(aSel); branch.ancestorCompounds.push_back( {aCompound.tag, aCompound.classes, aCompound.id}); } } branches.push_back(std::move(branch)); } Loading @@ -822,7 +910,8 @@ void libhtmlpp::CSSStyleSheet::collectApproximateMatches( const std::string &id, std::map<std::string,std::string> &props, std::string &mediaRules, std::set<std::string> &seenMediaBlocks) const std::set<std::string> &seenMediaBlocks, const std::vector<AncestorFrame> *ancestors) const { std::string tagLower = tag; std::transform(tagLower.begin(), tagLower.end(), tagLower.begin(), Loading Loading @@ -858,6 +947,14 @@ void libhtmlpp::CSSStyleSheet::collectApproximateMatches( compound.id = branch.id; if (!compoundMatches(compound, branch.hadCombinator, tagLower, classes, id)) continue; if (ancestors && !branch.ancestorCompounds.empty()) { std::vector<CompoundParts> ancestorCompounds; for (const auto &ac : branch.ancestorCompounds) { ancestorCompounds.push_back({ac.tag, ac.classes, ac.id}); } if (!ancestorChainSatisfies(ancestorCompounds, *ancestors)) continue; } if (branch.isAtRule) { // The same @media block, once present anywhere in the final // output, applies document-wide regardless of which element Loading src/css.h +71 −17 Original line number Diff line number Diff line Loading @@ -101,6 +101,24 @@ namespace libhtmlpp { CSSDeclaration _Declaration; }; /** One ancestor of the element being matched, for the optional ancestor * chain approximateSelectorMatch/collectApproximateMatches/getCSSRules * accept -- @p classes pre-split (whitespace-separated tokens of the * ancestor's own `class` attribute), same shape callers already need to * build for the target element. A caller passing a chain orders it * outermost-ancestor-first, closest-ancestor (immediate parent) last -- * the same left-to-right order a selector's own text reads in (e.g. for * ".a .b .c", ancestors[0] should be the frame checked against ".a"). * Only presence/order in the chain is checked, not adjacency or * combinator type: this remains an approximation of real CSS descendant * matching, just a considerably safer one than ignoring ancestors * entirely. */ struct AncestorFrame { std::string tag; std::vector<std::string> classes; std::string id; }; class CSSStyleSheet { public: CSSStyleSheet(); Loading @@ -127,31 +145,47 @@ namespace libhtmlpp { /** Conservative, NOT spec-complete selector match: @p selector (a * single selector, or a comma-separated list of them -- matches if * ANY branch matches) is matched against a single element * identified by @p tag/@p classes/@p id only -- there is no * ancestor/parent context available, so * any selector this can't safely evaluate without seeing more than * that one element is REJECTED (returns false) rather than guessed. * Rejected outright: attribute selectors ("[href]"), pseudo-classes * and pseudo-elements (":hover", "::before"), the universal selector * identified by @p tag/@p classes/@p id, optionally with @p * ancestors (see AncestorFrame) checked against any compound(s) * left of the trailing/target one. Passing @p ancestors as nullptr * (the default) means "verify the target compound only, same as if * no ancestor/parent context were available" -- every leading * compound of a combinator selector is then ignored, not verified, * matching this function's original behavior exactly (existing * callers that can't supply ancestor context are unaffected). When * @p ancestors is non-null, each leading compound must be found * somewhere in it, in left-to-right order (see AncestorFrame) -- * same all-classes-listed/tag/id rule as the target compound, but * without the "bare tag" rejection below, since a specific * candidate ancestor to check it against removes that hazard. * Rejected outright for the target compound: pseudo-classes and * pseudo-elements (":hover", "::before"), the universal selector * ("*"), and -- after a combinator (space/">"/"+"/"~") is stripped * down to its trailing compound selector -- a bare tag left with no * class/id qualifier of its own (e.g. "[data-x]>div" reduces to a * bare "div", which would otherwise match every element of that tag * in the whole document; a qualified compound like "div.foo" is * still specific enough to check). A compound selector like * still specific enough to check). Attribute selectors ("[href]") * have their "[...]" segment removed and the remaining compound (if * any) checked instead of being rejected outright -- this matcher * has no per-element attribute map to verify the condition itself, * so the condition is simply ignored rather than guessed, for both * the target and any ancestor compound. A compound selector like * "div.card.featured#hero" matches only if the tag (when given), * every class listed (all of them, not just one), and the id (when * given) are all present on the element. This is intentionally an * approximation of real CSS selector matching, not an * implementation of it -- there is no specificity calculation and * no combinator/ancestor verification -- built to be safe against * false positives on real-world scraped markup rather than * complete; treat a `false` result as "not proven to match", not * "definitely doesn't". */ * given) are all present on the element being checked against it. * This is intentionally an approximation of real CSS selector * matching, not an implementation of it -- there is no specificity * calculation, and ancestor verification (when requested) doesn't * distinguish child/descendant/sibling combinators or require * adjacency -- built to be safe against false positives on * real-world scraped markup rather than complete; treat a `false` * result as "not proven to match", not "definitely doesn't". */ static bool approximateSelectorMatch(const std::string &selector, const std::string &tag, const std::vector<std::string> &classes, const std::string &id); const std::string &id, const std::vector<AncestorFrame> *ancestors = nullptr); /** Runs every rule in this sheet through approximateSelectorMatch * against the element identified by @p tag/@p cssClass (a Loading @@ -169,13 +203,19 @@ namespace libhtmlpp { * @p seenMediaBlocks (both are caller-owned, so a caller processing * many elements from one document/sheet can share one instance of * each across all of them and avoid repeating an identical block * once per matching element). */ * once per matching element). @p ancestors is forwarded to * approximateSelectorMatch's underlying per-compound check exactly * as described there -- nullptr (the default) preserves this * function's original behavior (leading compounds of a combinator * selector are never verified), a non-null chain makes leading * compounds required to be found in it, in order. */ void collectApproximateMatches(const std::string &tag, const std::string &cssClass, const std::string &id, std::map<std::string,std::string> &props, std::string &mediaRules, std::set<std::string> &seenMediaBlocks) const; std::set<std::string> &seenMediaBlocks, const std::vector<AncestorFrame> *ancestors = nullptr) const; private: void _skipWhitespace(const std::string &input, size_t &pos) const; Loading Loading @@ -203,6 +243,20 @@ namespace libhtmlpp { std::vector<std::string> classes; std::string id; bool hadCombinator; // Every compound left of the target one above, left-to-right/ // outermost-first (e.g. for ".a .b .c", this holds ".a" and ".b" // -- "c" is the tag/classes/id/hadCombinator fields already // above). Empty tag/classes/id within one entry means that // compound's syntax couldn't be safely evaluated (same // attribute-selector/pseudo-class handling as the target) -- // ancestorChainSatisfies treats that as vacuously satisfied // rather than an unmet requirement. struct AncestorCompound { std::string tag; std::vector<std::string> classes; std::string id; }; std::vector<AncestorCompound> ancestorCompounds; }; mutable std::vector<std::vector<_CompoundCacheBranch>> _compoundCache; mutable bool _compoundCacheValid = false; Loading src/htmlcss.h +2 −1 Original line number Diff line number Diff line Loading @@ -78,6 +78,7 @@ namespace libhtmlpp { * CSSStyleSheet::collectApproximateMatches. */ CSSRuleResult getCSSRules(HtmlElement &target, const CSSStyleSheet &sheet, std::set<std::string> &seenMediaBlocks); std::set<std::string> &seenMediaBlocks, const std::vector<AncestorFrame> *ancestors = nullptr); } test/htmlcsstest.cpp +54 −0 Original line number Diff line number Diff line Loading @@ -91,6 +91,60 @@ int main(){ "a qualified compound after a combinator is still specific enough to check"); } // --- approximateSelectorMatch: ancestor chain verification --- // Regression coverage for a real-world bug: a descendant selector like // ".header .frame" was being matched against ANY ".frame" element in the // whole document, because this matcher never checked the ".header" part // against anything (see approximateSelectorMatch's own doc comment). On // a real page-builder site, this made a header-only padding rule // silently apply to every section on the page, collapsing all of their // intended vertical spacing. std::cout << "=== approximateSelectorMatch with ancestor chain ===" << std::endl; { using libhtmlpp::CSSStyleSheet; using libhtmlpp::AncestorFrame; std::vector<std::string> classes = {"frame"}; check(CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, ""), "with no ancestor chain given (nullptr), leading compounds are still ignored -- unchanged behavior"); std::vector<AncestorFrame> matchingAncestors = { {"div", {"header"}, ""}, }; check(CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, "", &matchingAncestors), "leading compound found in the supplied ancestor chain -- matches"); std::vector<AncestorFrame> unrelatedAncestors = { {"div", {"hero"}, ""}, }; check(!CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, "", &unrelatedAncestors), "leading compound absent from the supplied ancestor chain -- no longer matches (the actual bug)"); check(!CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, "", static_cast<const std::vector<AncestorFrame>*>(nullptr)), "sanity: explicit nullptr behaves the same as the default"); std::vector<AncestorFrame> outOfOrderAncestors = { {"div", {"b"}, ""}, {"div", {"a"}, ""}, }; check(!CSSStyleSheet::approximateSelectorMatch(".a .b .frame", "div", classes, "", &outOfOrderAncestors), "ancestor compounds must be found in left-to-right order, not just anywhere in the chain"); std::vector<AncestorFrame> inOrderAncestors = { {"div", {"a"}, ""}, {"div", {"b"}, ""}, }; check(CSSStyleSheet::approximateSelectorMatch(".a .b .frame", "div", classes, "", &inOrderAncestors), "same two ancestors in the correct left-to-right order do match"); std::vector<AncestorFrame> onlyOuterAncestor = { {"div", {"a"}, ""}, }; check(!CSSStyleSheet::approximateSelectorMatch(".a .b .frame", "div", classes, "", &onlyOuterAncestor), "a missing middle ancestor compound still fails the match"); } // --- collectApproximateMatches: cascade priority --- std::cout << "=== collectApproximateMatches cascade ===" << std::endl; { Loading Loading
src/css.cpp +137 −40 Original line number Diff line number Diff line Loading @@ -103,17 +103,49 @@ namespace { return result; } // Whether `compound`'s tag/classes/id are ALL present on the given // candidate tag/classes/id -- every class listed must be present, not // just one. Shared by compoundMatches (the target element, which adds // its own "bare tag after combinator" guard on top -- see there) and // ancestorChainSatisfies (a specific ancestor frame, which needs no such // guard: a lone tag requirement checked against one concrete candidate // is a meaningful constraint, not the blanket-match hazard a lone tag // with no ancestor to check it against would be). A compound with // nothing specified at all (tag/classes/id all empty -- e.g. it was // purely an attribute selector or pseudo-class, see // stripAttributeSelectors/hasUnsupportedSelectorSyntax) trivially // matches anything: this helper has no opinion on whether an empty // compound should count as a real requirement, that's each caller's // call (compoundMatches rejects it via specifiedSomething, // ancestorChainSatisfies treats it as vacuously satisfied). bool compoundPartsMatch(const CompoundParts &compound, const std::string &tag, const std::vector<std::string> &classes, const std::string &id) { bool tagOk = compound.tag.empty() || compound.tag == tag; bool idOk = compound.id.empty() || compound.id == id; bool classesOk = true; for (const auto &cls : compound.classes) { if (std::find(classes.begin(), classes.end(), cls) == classes.end()) { classesOk = false; break; } } return tagOk && idOk && classesOk; } // A selector matches only if EVERY part it specifies is present on this // element -- in particular every class listed, not just one. A bare tag // left over after stripping a combinator's ancestor part (e.g. // "[data-atom=header]>div" reduces to bare "div") can't be verified -- // this matcher only ever sees the CURRENT element's own tag/class/id, // never its ancestor chain -- so treating it as a match would apply the // rule to literally every element of that tag in the whole document // (confirmed against a real scraped page where exactly this pattern // matched ~24,000 times and blew up an HTML-import's output to 36MB). A // class/id qualifier alongside the tag (e.g. "div.foo") is still // specific enough to check. // "[data-atom=header]>div" reduces to bare "div") can't be verified when // no ancestor chain is available to check it against -- treating it as a // match would apply the rule to literally every element of that tag in // the whole document (confirmed against a real scraped page where // exactly this pattern matched ~24,000 times and blew up an HTML-import's // output to 36MB). A class/id qualifier alongside the tag (e.g. // "div.foo") is still specific enough to check, and this guard only ever // applies to the trailing/target compound -- ancestorChainSatisfies // checks leading compounds against a real candidate frame instead, so a // bare-tag ancestor requirement is safe there. bool compoundMatches(const CompoundParts &compound, bool hadCombinator, const std::string &tag, const std::vector<std::string> &classes, const std::string &id) Loading @@ -123,34 +155,68 @@ namespace { bool specifiedSomething = !compound.tag.empty() || !compound.classes.empty() || !compound.id.empty(); if (bareTagAfterCombinator || !specifiedSomething) return false; bool tagOk = compound.tag.empty() || compound.tag == tag; bool idOk = compound.id.empty() || compound.id == id; bool classesOk = true; for (const auto &cls : compound.classes) { if (std::find(classes.begin(), classes.end(), cls) == classes.end()) { classesOk = false; return compoundPartsMatch(compound, tag, classes, id); } // Splits a single (already comma-branch-isolated) selector into its // combinator-separated compounds, left-to-right / outermost-ancestor- // first -- e.g. ".a .b > .c" -> [".a", ".b", ".c"]. Combinator type // (space/">"/"+"/"~") is discarded, same simplification this matcher // always made even when it only ever looked at the trailing compound: // it never distinguished child from descendant/sibling combinators. // Verifying ancestors approximately -- present somewhere in the chain, // in left-to-right order, not necessarily adjacent -- is still far more // correct than not verifying them at all (see ancestorChainSatisfies). std::vector<std::string> splitCombinatorChain(const std::string &selector) { std::vector<std::string> parts; std::string cur; for (char c : selector) { if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '>' || c == '+' || c == '~') { if (!cur.empty()) { parts.push_back(cur); cur.clear(); } } else { cur += c; } } if (!cur.empty()) parts.push_back(cur); return parts; } // Whether every compound in `ancestorCompounds` (left-to-right / // outermost-first -- see splitCombinatorChain) can be matched, in that // order, against some frame in `ancestors` (also outermost-first, // closest/immediate-parent last -- see AncestorFrame). Each compound // consumes the ancestor it matched and everything closer to the target // than it, so a later compound can never be satisfied by an ancestor // farther out than one an earlier compound already matched -- // approximating real left-to-right descendant-selector semantics // without distinguishing combinator type, requiring adjacency, or // computing true specificity. An empty compound (unsafe syntax reduced // to nothing, see splitCombinatorChain's callers) is vacuously satisfied // without consuming an ancestor slot -- an unverifiable requirement // shouldn't by itself invalidate an otherwise plausible match, same // "not proven to not match" philosophy as the rest of this file. bool ancestorChainSatisfies(const std::vector<CompoundParts> &ancestorCompounds, const std::vector<libhtmlpp::AncestorFrame> &ancestors) { size_t idx = 0; for (const auto &compound : ancestorCompounds) { bool specifiedSomething = !compound.tag.empty() || !compound.classes.empty() || !compound.id.empty(); if (!specifiedSomething) continue; bool found = false; while (idx < ancestors.size()) { const libhtmlpp::AncestorFrame &frame = ancestors[idx]; ++idx; if (compoundPartsMatch(compound, frame.tag, frame.classes, frame.id)) { found = true; break; } } return tagOk && idOk && classesOk; } // Strips a trailing combinator (space/">"/"+"/"~") from `singleSel`, // returning the trailing compound selector text and whether a // combinator was actually present. Only the trailing compound is ever // checked -- this matcher never verifies ancestor/parent context. std::string stripCombinator(const std::string &singleSel, bool &hadCombinator) { std::string matchSel = singleSel; hadCombinator = false; size_t lastSep = matchSel.find_last_of(" >+~"); if (lastSep != std::string::npos) { size_t mstart = matchSel.find_first_not_of(" >+~", lastSep); if (mstart != std::string::npos) { matchSel = matchSel.substr(mstart); hadCombinator = true; if (!found) return false; } } return matchSel; return true; } // Selector syntax this matcher can't safely evaluate -- pseudo- Loading Loading @@ -685,7 +751,8 @@ libhtmlpp::CSSDeclaration libhtmlpp::CSSStyleSheet::parseInlineStyle(const std:: bool libhtmlpp::CSSStyleSheet::approximateSelectorMatch(const std::string &selector, const std::string &tag, const std::vector<std::string> &classes, const std::string &id) const std::string &id, const std::vector<AncestorFrame> *ancestors) { std::string tagLower = tag; std::transform(tagLower.begin(), tagLower.end(), tagLower.begin(), Loading @@ -699,13 +766,26 @@ bool libhtmlpp::CSSStyleSheet::approximateSelectorMatch(const std::string &selec size_t end = singleSel.find_last_not_of(" \t\n\r"); singleSel = singleSel.substr(start, end - start + 1); bool hadCombinator = false; std::string matchSel = stripCombinator(singleSel, hadCombinator); matchSel = stripAttributeSelectors(matchSel); std::vector<std::string> chain = splitCombinatorChain(singleSel); if (chain.empty()) continue; bool hadCombinator = chain.size() > 1; std::string matchSel = stripAttributeSelectors(chain.back()); if (hasUnsupportedSelectorSyntax(matchSel)) continue; CompoundParts compound = parseCompoundSelector(matchSel); if (compoundMatches(compound, hadCombinator, tagLower, classes, id)) return true; if (!compoundMatches(compound, hadCombinator, tagLower, classes, id)) continue; if (ancestors && chain.size() > 1) { std::vector<CompoundParts> ancestorCompounds; for (size_t i = 0; i + 1 < chain.size(); ++i) { std::string aSel = stripAttributeSelectors(chain[i]); ancestorCompounds.push_back( hasUnsupportedSelectorSyntax(aSel) ? CompoundParts{} : parseCompoundSelector(aSel)); } if (!ancestorChainSatisfies(ancestorCompounds, *ancestors)) continue; } return true; } return false; } Loading Loading @@ -792,10 +872,11 @@ void libhtmlpp::CSSStyleSheet::_rebuildCompoundCache() const { branch.atWrapper = atWrapper; branch.trimmedSelector = singleSel; bool hadCombinator = false; std::string matchSel = stripCombinator(singleSel, hadCombinator); matchSel = stripAttributeSelectors(matchSel); if (hasUnsupportedSelectorSyntax(matchSel)) { std::vector<std::string> chain = splitCombinatorChain(singleSel); bool hadCombinator = chain.size() > 1; std::string matchSel = chain.empty() ? std::string() : stripAttributeSelectors(chain.back()); if (chain.empty() || hasUnsupportedSelectorSyntax(matchSel)) { branch.skip = true; } else { CompoundParts compound = parseCompoundSelector(matchSel); Loading @@ -804,6 +885,13 @@ void libhtmlpp::CSSStyleSheet::_rebuildCompoundCache() const { branch.classes = compound.classes; branch.id = compound.id; branch.hadCombinator = hadCombinator; for (size_t i = 0; i + 1 < chain.size(); ++i) { std::string aSel = stripAttributeSelectors(chain[i]); CompoundParts aCompound = hasUnsupportedSelectorSyntax(aSel) ? CompoundParts{} : parseCompoundSelector(aSel); branch.ancestorCompounds.push_back( {aCompound.tag, aCompound.classes, aCompound.id}); } } branches.push_back(std::move(branch)); } Loading @@ -822,7 +910,8 @@ void libhtmlpp::CSSStyleSheet::collectApproximateMatches( const std::string &id, std::map<std::string,std::string> &props, std::string &mediaRules, std::set<std::string> &seenMediaBlocks) const std::set<std::string> &seenMediaBlocks, const std::vector<AncestorFrame> *ancestors) const { std::string tagLower = tag; std::transform(tagLower.begin(), tagLower.end(), tagLower.begin(), Loading Loading @@ -858,6 +947,14 @@ void libhtmlpp::CSSStyleSheet::collectApproximateMatches( compound.id = branch.id; if (!compoundMatches(compound, branch.hadCombinator, tagLower, classes, id)) continue; if (ancestors && !branch.ancestorCompounds.empty()) { std::vector<CompoundParts> ancestorCompounds; for (const auto &ac : branch.ancestorCompounds) { ancestorCompounds.push_back({ac.tag, ac.classes, ac.id}); } if (!ancestorChainSatisfies(ancestorCompounds, *ancestors)) continue; } if (branch.isAtRule) { // The same @media block, once present anywhere in the final // output, applies document-wide regardless of which element Loading
src/css.h +71 −17 Original line number Diff line number Diff line Loading @@ -101,6 +101,24 @@ namespace libhtmlpp { CSSDeclaration _Declaration; }; /** One ancestor of the element being matched, for the optional ancestor * chain approximateSelectorMatch/collectApproximateMatches/getCSSRules * accept -- @p classes pre-split (whitespace-separated tokens of the * ancestor's own `class` attribute), same shape callers already need to * build for the target element. A caller passing a chain orders it * outermost-ancestor-first, closest-ancestor (immediate parent) last -- * the same left-to-right order a selector's own text reads in (e.g. for * ".a .b .c", ancestors[0] should be the frame checked against ".a"). * Only presence/order in the chain is checked, not adjacency or * combinator type: this remains an approximation of real CSS descendant * matching, just a considerably safer one than ignoring ancestors * entirely. */ struct AncestorFrame { std::string tag; std::vector<std::string> classes; std::string id; }; class CSSStyleSheet { public: CSSStyleSheet(); Loading @@ -127,31 +145,47 @@ namespace libhtmlpp { /** Conservative, NOT spec-complete selector match: @p selector (a * single selector, or a comma-separated list of them -- matches if * ANY branch matches) is matched against a single element * identified by @p tag/@p classes/@p id only -- there is no * ancestor/parent context available, so * any selector this can't safely evaluate without seeing more than * that one element is REJECTED (returns false) rather than guessed. * Rejected outright: attribute selectors ("[href]"), pseudo-classes * and pseudo-elements (":hover", "::before"), the universal selector * identified by @p tag/@p classes/@p id, optionally with @p * ancestors (see AncestorFrame) checked against any compound(s) * left of the trailing/target one. Passing @p ancestors as nullptr * (the default) means "verify the target compound only, same as if * no ancestor/parent context were available" -- every leading * compound of a combinator selector is then ignored, not verified, * matching this function's original behavior exactly (existing * callers that can't supply ancestor context are unaffected). When * @p ancestors is non-null, each leading compound must be found * somewhere in it, in left-to-right order (see AncestorFrame) -- * same all-classes-listed/tag/id rule as the target compound, but * without the "bare tag" rejection below, since a specific * candidate ancestor to check it against removes that hazard. * Rejected outright for the target compound: pseudo-classes and * pseudo-elements (":hover", "::before"), the universal selector * ("*"), and -- after a combinator (space/">"/"+"/"~") is stripped * down to its trailing compound selector -- a bare tag left with no * class/id qualifier of its own (e.g. "[data-x]>div" reduces to a * bare "div", which would otherwise match every element of that tag * in the whole document; a qualified compound like "div.foo" is * still specific enough to check). A compound selector like * still specific enough to check). Attribute selectors ("[href]") * have their "[...]" segment removed and the remaining compound (if * any) checked instead of being rejected outright -- this matcher * has no per-element attribute map to verify the condition itself, * so the condition is simply ignored rather than guessed, for both * the target and any ancestor compound. A compound selector like * "div.card.featured#hero" matches only if the tag (when given), * every class listed (all of them, not just one), and the id (when * given) are all present on the element. This is intentionally an * approximation of real CSS selector matching, not an * implementation of it -- there is no specificity calculation and * no combinator/ancestor verification -- built to be safe against * false positives on real-world scraped markup rather than * complete; treat a `false` result as "not proven to match", not * "definitely doesn't". */ * given) are all present on the element being checked against it. * This is intentionally an approximation of real CSS selector * matching, not an implementation of it -- there is no specificity * calculation, and ancestor verification (when requested) doesn't * distinguish child/descendant/sibling combinators or require * adjacency -- built to be safe against false positives on * real-world scraped markup rather than complete; treat a `false` * result as "not proven to match", not "definitely doesn't". */ static bool approximateSelectorMatch(const std::string &selector, const std::string &tag, const std::vector<std::string> &classes, const std::string &id); const std::string &id, const std::vector<AncestorFrame> *ancestors = nullptr); /** Runs every rule in this sheet through approximateSelectorMatch * against the element identified by @p tag/@p cssClass (a Loading @@ -169,13 +203,19 @@ namespace libhtmlpp { * @p seenMediaBlocks (both are caller-owned, so a caller processing * many elements from one document/sheet can share one instance of * each across all of them and avoid repeating an identical block * once per matching element). */ * once per matching element). @p ancestors is forwarded to * approximateSelectorMatch's underlying per-compound check exactly * as described there -- nullptr (the default) preserves this * function's original behavior (leading compounds of a combinator * selector are never verified), a non-null chain makes leading * compounds required to be found in it, in order. */ void collectApproximateMatches(const std::string &tag, const std::string &cssClass, const std::string &id, std::map<std::string,std::string> &props, std::string &mediaRules, std::set<std::string> &seenMediaBlocks) const; std::set<std::string> &seenMediaBlocks, const std::vector<AncestorFrame> *ancestors = nullptr) const; private: void _skipWhitespace(const std::string &input, size_t &pos) const; Loading Loading @@ -203,6 +243,20 @@ namespace libhtmlpp { std::vector<std::string> classes; std::string id; bool hadCombinator; // Every compound left of the target one above, left-to-right/ // outermost-first (e.g. for ".a .b .c", this holds ".a" and ".b" // -- "c" is the tag/classes/id/hadCombinator fields already // above). Empty tag/classes/id within one entry means that // compound's syntax couldn't be safely evaluated (same // attribute-selector/pseudo-class handling as the target) -- // ancestorChainSatisfies treats that as vacuously satisfied // rather than an unmet requirement. struct AncestorCompound { std::string tag; std::vector<std::string> classes; std::string id; }; std::vector<AncestorCompound> ancestorCompounds; }; mutable std::vector<std::vector<_CompoundCacheBranch>> _compoundCache; mutable bool _compoundCacheValid = false; Loading
src/htmlcss.h +2 −1 Original line number Diff line number Diff line Loading @@ -78,6 +78,7 @@ namespace libhtmlpp { * CSSStyleSheet::collectApproximateMatches. */ CSSRuleResult getCSSRules(HtmlElement &target, const CSSStyleSheet &sheet, std::set<std::string> &seenMediaBlocks); std::set<std::string> &seenMediaBlocks, const std::vector<AncestorFrame> *ancestors = nullptr); }
test/htmlcsstest.cpp +54 −0 Original line number Diff line number Diff line Loading @@ -91,6 +91,60 @@ int main(){ "a qualified compound after a combinator is still specific enough to check"); } // --- approximateSelectorMatch: ancestor chain verification --- // Regression coverage for a real-world bug: a descendant selector like // ".header .frame" was being matched against ANY ".frame" element in the // whole document, because this matcher never checked the ".header" part // against anything (see approximateSelectorMatch's own doc comment). On // a real page-builder site, this made a header-only padding rule // silently apply to every section on the page, collapsing all of their // intended vertical spacing. std::cout << "=== approximateSelectorMatch with ancestor chain ===" << std::endl; { using libhtmlpp::CSSStyleSheet; using libhtmlpp::AncestorFrame; std::vector<std::string> classes = {"frame"}; check(CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, ""), "with no ancestor chain given (nullptr), leading compounds are still ignored -- unchanged behavior"); std::vector<AncestorFrame> matchingAncestors = { {"div", {"header"}, ""}, }; check(CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, "", &matchingAncestors), "leading compound found in the supplied ancestor chain -- matches"); std::vector<AncestorFrame> unrelatedAncestors = { {"div", {"hero"}, ""}, }; check(!CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, "", &unrelatedAncestors), "leading compound absent from the supplied ancestor chain -- no longer matches (the actual bug)"); check(!CSSStyleSheet::approximateSelectorMatch(".header .frame", "div", classes, "", static_cast<const std::vector<AncestorFrame>*>(nullptr)), "sanity: explicit nullptr behaves the same as the default"); std::vector<AncestorFrame> outOfOrderAncestors = { {"div", {"b"}, ""}, {"div", {"a"}, ""}, }; check(!CSSStyleSheet::approximateSelectorMatch(".a .b .frame", "div", classes, "", &outOfOrderAncestors), "ancestor compounds must be found in left-to-right order, not just anywhere in the chain"); std::vector<AncestorFrame> inOrderAncestors = { {"div", {"a"}, ""}, {"div", {"b"}, ""}, }; check(CSSStyleSheet::approximateSelectorMatch(".a .b .frame", "div", classes, "", &inOrderAncestors), "same two ancestors in the correct left-to-right order do match"); std::vector<AncestorFrame> onlyOuterAncestor = { {"div", {"a"}, ""}, }; check(!CSSStyleSheet::approximateSelectorMatch(".a .b .frame", "div", classes, "", &onlyOuterAncestor), "a missing middle ancestor compound still fails the match"); } // --- collectApproximateMatches: cascade priority --- std::cout << "=== collectApproximateMatches cascade ===" << std::endl; { Loading