Commit 080183e9 authored by jan.koester's avatar jan.koester
Browse files

test

parent 767a76a4
Loading
Loading
Loading
Loading
+98 −240
Changes for editor/src/htmlimport.cpp: 98 added lines, 240 removed lines.
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@
 *******************************************************************************/

#include "htmlimport.h"
#include "plugin.h"

#include <algorithm>
#include <cctype>
@@ -352,243 +353,89 @@ std::string extractBackgroundColorFromShorthand(const std::string &value)
    return candidateCount == 1 ? candidate : std::string();
}

} // namespace

std::map<std::string,std::string>
blogi::htmlimport::declarationToMap(
    const libhtmlpp::CSSDeclaration &decl)
// Derives "background-color" from a "background" shorthand value already
// present in `props` -- see extractBackgroundColorFromShorthand's doc
// comment for why: every widget's known-CSS mapping only ever looks for the
// longhand "background-color", never "background" itself. This only ever
// looks at the FINAL cascaded value of "background" (libhtmlpp::getCSSRules
// has already resolved inline/stylesheet-rule precedence by the time this
// runs), unlike the old per-rule derivation this replaces, which re-derived
// mid-cascade for every matching rule that touched "background" -- a
// simplification that only matters for the rare case of multiple
// conflicting background/background-color rules with mixed !important on
// the same element.
void applyBackgroundColorShorthand(std::map<std::string,std::string> &props)
{
    std::map<std::string,std::string> result;
    for (const auto &prop : decl.getProperties()) {
        std::string value = prop.getValue();
        stripImportant(value);
        result[prop.getName()] = value;
    }

    if (result.count("background") && !result.count("background-color")) {
        std::string bg = extractBackgroundColorFromShorthand(result["background"]);
        if (!bg.empty()) result["background-color"] = bg;
    }

    return result;
}

void blogi::htmlimport::mergeStylesheetRules(
    const libhtmlpp::CSSStyleSheet &sheet,
    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)
    if (props.count("background") && !props.count("background-color")) {
        std::string bg = extractBackgroundColorFromShorthand(props["background"]);
        if (!bg.empty()) props["background-color"] = bg;
    }
}

// Combines longhand CSS properties into the single shorthand key each
// widget's XML schema actually has an attribute for -- run once per element,
// after libhtmlpp::getCSSRules has already resolved cascade precedence, and
// before the per-widget-tag mapping below (which only ever looks for
// "border"/"padding"/"margin", never their longhand pieces -- see e.g.
// textbox.cpp's border_style/padding_top.../margin_top... attributes). Only
// fires when the shorthand key is itself absent -- if the source HTML
// already set e.g. "border" directly, any longhand pieces also present are
// left untouched and fall through to extractCustomCss unchanged (same as
// before this function existed), since a page mixing shorthand and a
// longhand override for one component (e.g. "border: 1px solid black;
// border-color: red;") needs that override to keep applying, which erasing
// it here would silently break.
void normalizeShorthandProperties(std::map<std::string,std::string> &props)
{
    std::vector<std::string> classes;
    if (!cssClass.empty()) {
        std::istringstream iss(cssClass);
        std::string cls;
        while (iss >> cls) {
            classes.push_back(cls);
        }
    }

    // Property names already present in @p props when we're called came from
    // this element's own inline style="..." (htmlElementToWidgetXml builds
    // props from inline style before calling this) -- inline style always
    // outranks a plain stylesheet rule, unless that rule is "!important"
    // (see isImportant below), matching real cascade precedence.
    std::set<std::string> inlineKeys;
    for (const auto &kv : props) inlineKeys.insert(kv.first);
    std::set<std::string> importantKeys;

    for (const auto &rule : sheet.getRules()) {
        const std::string &sel = rule.getSelector();
        if (sel.empty()) continue;

        bool isAtRule = (!sel.empty() && sel[0] == '@');
        std::string atWrapper;
        std::string innerSel;

        if (isAtRule) {
            size_t parenDepth = 0;
            size_t splitPos = std::string::npos;
            for (size_t i = 0; i < sel.size(); ++i) {
                if (sel[i] == '(') ++parenDepth;
                else if (sel[i] == ')') {
                    if (parenDepth > 0) --parenDepth;
                    if (parenDepth == 0) {
                        splitPos = i + 1;
                        break;
                    }
                }
            }
            if (splitPos != std::string::npos && splitPos < sel.size()) {
                atWrapper = sel.substr(0, splitPos);
                size_t innerStart = sel.find_first_not_of(" \t\n\r", splitPos);
                if (innerStart != std::string::npos) {
                    innerSel = sel.substr(innerStart);
    if (!props.count("border")) {
        std::string combined;
        auto append = [&](const char *key) {
            auto it = props.find(key);
            if (it != props.end() && !it->second.empty()) {
                if (!combined.empty()) combined += " ";
                combined += it->second;
            }
        };
        append("border-width");
        append("border-style");
        append("border-color");
        if (!combined.empty()) {
            props["border"] = combined;
            props.erase("border-width");
            props.erase("border-style");
            props.erase("border-color");
        }
    }

    // A valid CSS box-model shorthand value must cover all four sides --
    // there's no way to represent "only top and left were specified" without
    // inventing values for the other two, and guessing wrong is worse than
    // not filling in the shortcut attribute (same philosophy as
    // extractBackgroundColorFromShorthand). So this only fires when all four
    // longhand sides are present; a partial set is left as-is and falls
    // through to extractCustomCss individually, same as before.
    auto joinSides = [&](const char *shorthandKey, const char *topKey, const char *rightKey,
                          const char *bottomKey, const char *leftKey) {
        if (props.count(shorthandKey)) return;
        if (!props.count(topKey) || !props.count(rightKey) ||
            !props.count(bottomKey) || !props.count(leftKey)) return;

        // Reuses the widget-side helper (plugin.h) that migrates this exact
        // shorthand back into per-side attributes on load -- so this
        // synthesized value round-trips through the same, already-proven
        // path as a hand-authored legacy document.
        props[shorthandKey] = blogi::joinSpacingShorthand(
            props[topKey], props[rightKey], props[bottomKey], props[leftKey]);
        props.erase(topKey);
        props.erase(rightKey);
        props.erase(bottomKey);
        props.erase(leftKey);
    };
    joinSides("padding", "padding-top", "padding-right", "padding-bottom", "padding-left");
    joinSides("margin", "margin-top", "margin-right", "margin-bottom", "margin-left");
}
            if (innerSel.empty()) continue;
        }

        const std::string &matchTarget = isAtRule ? innerSel : sel;

        std::istringstream selStream(matchTarget);
        std::string singleSel;
        while (std::getline(selStream, singleSel, ',')) {
            size_t start = singleSel.find_first_not_of(" \t\n\r");
            size_t end = singleSel.find_last_not_of(" \t\n\r");
            if (start == std::string::npos) continue;
            singleSel = singleSel.substr(start, end - start + 1);

            std::string matchSel = singleSel;
            bool 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;
                    }
                }
            }

            bool matches = false;

            // Reject selector syntax this function can't safely evaluate --
            // attribute selectors and pseudo-classes/elements describe
            // context (an ancestor's attribute) or state (":hover") that
            // this function has no way to check, and "*" would blanket-
            // match every element -- guessing wrong here is worse than not
            // matching at all (see bareTagAfterCombinator below for a
            // concrete case of exactly that going wrong).
            bool hasUnsupportedSyntax = matchSel.find('[') != std::string::npos ||
                                         matchSel.find(':') != std::string::npos ||
                                         matchSel == "*";

            if (!hasUnsupportedSyntax) {
                // Splits a compound simple selector (combinators already
                // stripped above) into an optional bare tag, every ".class"
                // token, and an optional "#id" token -- e.g.
                // "div.card.featured#hero" -> tag=div,
                // classes={card,featured}, idPart=hero. A selector matches
                // only if EVERY part it specifies is present on this
                // element -- in particular every class listed, not just
                // one (fixes ".a.b" previously being compared as the single
                // literal string "a.b" against individual class tokens and
                // so never matching at all).
                std::string tagPart;
                std::vector<std::string> classParts;
                std::string idPart;
                size_t pos = 0;
                while (pos < matchSel.size()) {
                    char c = matchSel[pos];
                    if (c == '.' || c == '#') {
                        size_t next = matchSel.find_first_of(".#", pos + 1);
                        size_t tokenEnd = (next == std::string::npos) ? matchSel.size() : next;
                        std::string token = matchSel.substr(pos + 1, tokenEnd - (pos + 1));
                        if (c == '.') classParts.push_back(token);
                        else idPart = token;
                        pos = tokenEnd;
                    } else {
                        size_t next = matchSel.find_first_of(".#", pos);
                        size_t tokenEnd = (next == std::string::npos) ? matchSel.size() : next;
                        tagPart = matchSel.substr(pos, tokenEnd - pos);
                        pos = tokenEnd;
                    }
                }
                std::transform(tagPart.begin(), tagPart.end(), tagPart.begin(),
                    [](unsigned char c) { return std::tolower(c); });

                // A bare tag left over after stripping a combinator's
                // ancestor part (e.g. "[data-atom=header]>div" reduces to
                // "div") can't be verified -- this function 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 the imported output to 36MB. A class/id qualifier
                // alongside the tag (e.g. "div.foo") is still specific
                // enough to check below.
                bool bareTagAfterCombinator = hadCombinator &&
                    classParts.empty() && idPart.empty() && !tagPart.empty();

                bool specifiedSomething = !tagPart.empty() || !classParts.empty() || !idPart.empty();

                if (!bareTagAfterCombinator && specifiedSomething) {
                    bool tagOk = tagPart.empty() || tagPart == tag;
                    bool idOk = idPart.empty() || idPart == id;
                    bool classesOk = true;
                    for (const auto &cls : classParts) {
                        if (std::find(classes.begin(), classes.end(), cls) == classes.end()) {
                            classesOk = false;
                            break;
                        }
                    }
                    if (tagOk && idOk && classesOk) matches = true;
                }
            }

            if (matches) {
                if (isAtRule) {
                    std::string block = atWrapper + " { " + singleSel + " { ";
                    for (const auto &prop : rule.getDeclaration().getProperties()) {
                        block += prop.getName() + ": " + prop.getValue() + "; ";
                    }
                    block += "} } ";
                    // The same @media block, once present anywhere in the
                    // final page's HTML, applies document-wide regardless of
                    // which widget's <style> tag carries it -- so including
                    // it more than once across the whole import is pure
                    // bloat, not a correctness requirement. Real scraped
                    // pages routinely have many widgets share one rule (e.g.
                    // by class), each independently re-matching it here.
                    if (seenMediaBlocks.insert(block).second) {
                        mediaRules += block;
                    }
                } else {
                    for (const auto &prop : rule.getDeclaration().getProperties()) {
                        std::string value = prop.getValue();
                        bool isImportant = stripImportant(value);
                        const std::string &key = prop.getName();

                        // Inline style and an already-!important value both
                        // outrank a later plain rule; a later rule of equal
                        // priority (both plain, or both !important) wins --
                        // approximating the real cascade's "last rule of
                        // equal-or-higher precedence wins" without computing
                        // full selector specificity.
                        if (inlineKeys.count(key) && !isImportant) continue;
                        if (importantKeys.count(key) && !isImportant) continue;

                        props[key] = value;
                        if (isImportant) importantKeys.insert(key);

                        // See extractBackgroundColorFromShorthand's doc
                        // comment -- widgets only ever look for the
                        // longhand "background-color", never "background"
                        // itself, so derive it here too under the exact
                        // same priority rules as any other property match.
                        if (key == "background") {
                            std::string bg = extractBackgroundColorFromShorthand(value);
                            if (!bg.empty()) {
                                const std::string bgKey = "background-color";
                                bool skip = (inlineKeys.count(bgKey) && !isImportant) ||
                                            (importantKeys.count(bgKey) && !isImportant);
                                if (!skip) {
                                    props[bgKey] = bg;
                                    if (isImportant) importantKeys.insert(bgKey);
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
} // namespace

std::string blogi::htmlimport::extractCustomCss(
    std::map<std::string,std::string> &props,
@@ -851,16 +698,22 @@ void blogi::htmlimport::htmlElementToWidgetXml(
                continue;
            }

            // Get inline style
            // Raw inline style text -- kept for the CustomHtml fallback
            // branch further below, which embeds it verbatim regardless of
            // whether libhtmlpp could parse/match anything out of it.
            std::string style = el->getAtributte("style");
            auto cssProps = declarationToMap(
                libhtmlpp::CSSStyleSheet::parseInlineStyle(style));

            std::string cssClass = el->getAtributte("class");
            std::string elemId = el->getAtributte("id");

            std::string mediaRules;
            mergeStylesheetRules(sheet, tag, cssClass, elemId, cssProps, mediaRules, seenMediaBlocks);
            // Every matching rule for this element -- its own inline style,
            // every <style> block, and any externally fetched stylesheet
            // already folded into `sheet` below -- fully cascaded.
            auto cssRules = libhtmlpp::getCSSRules(*el, sheet, seenMediaBlocks);
            auto cssProps = cssRules.properties;
            std::string mediaRules = cssRules.mediaRules;
            applyBackgroundColorShorthand(cssProps);
            normalizeShorthandProperties(cssProps);

            // ---- Map HTML tags to widget types ----

@@ -1276,6 +1129,10 @@ void blogi::htmlimport::htmlElementToWidgetXml(
                    w->SetAttribute("font_color", cssProps["color"].c_str());
                if (cssProps.count("text-align"))
                    w->SetAttribute("text_align", cssProps["text-align"].c_str());
                if (cssProps.count("font-weight"))
                    w->SetAttribute("font_weight", cssProps["font-weight"].c_str());
                if (cssProps.count("font-style"))
                    w->SetAttribute("font_style", cssProps["font-style"].c_str());
                if (!cssClass.empty())
                    w->SetAttribute("css_class", cssClass.c_str());
                if (!elemId.empty())
@@ -1831,7 +1688,7 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
        // <head>'s), scrambling the order rules end up in `stylesheet`
        // relative to the real document -- which matters once merging two
        // rules for the same property falls back to "later one wins" (see
        // mergeStylesheetRules).
        // libhtmlpp::CSSStyleSheet::collectApproximateMatches).
        std::vector<libhtmlpp::Element*> childFrames;

        while (cur) {
@@ -2032,7 +1889,8 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
            // emit "@media (...) :root { decls }", which is invalid CSS (the
            // at-rule needs its own wrapping braces around the nested rule).
            // Reconstruct the proper nested form instead, the same way
            // mergeStylesheetRules does for per-element @media matches.
            // libhtmlpp::CSSStyleSheet::collectApproximateMatches does for
            // per-element @media matches.
            globalCss += atWrapper + " {\n  " + innerSel + " {\n";
            for (const auto &prop : rule.getDeclaration().getProperties()) {
                globalCss += "    " + prop.getName() + ": " + prop.getValue() + ";\n";
@@ -2053,7 +1911,7 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,

    // Shared across every top-level node imported below (not per-node) so
    // an @media block already emitted via one node's widgets isn't repeated
    // by another's -- see mergeStylesheetRules's dedup comment.
    // by another's -- see collectApproximateMatches's dedup comment.
    std::set<std::string> seenMediaBlocks;

    if (root) {
+1 −22
Changes for editor/src/htmlimport.h: 1 added line, 22 removed lines.
Original line number Diff line number Diff line
@@ -36,6 +36,7 @@

#include <htmlpp/html.h>
#include <htmlpp/css.h>
#include <htmlpp/htmlcss.h>
#include <tinyxml2.h>

namespace blogi {
@@ -71,28 +72,6 @@ namespace htmlimport {
        int jsFailed = 0;
    };

    std::map<std::string,std::string> declarationToMap(
        const libhtmlpp::CSSDeclaration &decl);

    /** @p seenMediaBlocks accumulates the exact text of every @media block
     *  already appended to some element's @p mediaRules across the whole
     *  import (see convertHtmlToWidgetXml/htmlElementToWidgetXml, which
     *  share one instance across every element processed) -- a block whose
     *  text is already present is skipped rather than appended again. This
     *  is safe because a <style> block's effect is document-wide regardless
     *  of which widget's XML happens to carry it, and it matters because a
     *  rule matched by a real page can independently match thousands of
     *  elements (e.g. by shared class), each otherwise re-appending the
     *  identical block text. */
    void mergeStylesheetRules(
        const libhtmlpp::CSSStyleSheet &sheet,
        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);

    std::string extractCustomCss(
        std::map<std::string,std::string> &props,
        const std::vector<std::string> &knownKeys,