Commit 9d357063 authored by jan.koester's avatar jan.koester
Browse files

anocestor frame support

parent f78e3f4c
Loading
Loading
Loading
Loading
+137 −40
Original line number Diff line number Diff line
@@ -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)
@@ -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-
@@ -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(),
@@ -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;
}
@@ -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);
@@ -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));
                }
@@ -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(),
@@ -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
+71 −17
Original line number Diff line number Diff line
@@ -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();
@@ -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
@@ -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;
@@ -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;
+2 −1
Original line number Diff line number Diff line
@@ -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);

}
+54 −0
Original line number Diff line number Diff line
@@ -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;
    {