Loading editor/src/htmlimport.cpp +144 −50 Changes for editor/src/htmlimport.cpp: 144 added lines, 50 removed lines. Original line number Diff line number Diff line Loading @@ -243,13 +243,47 @@ bool blogi::htmlimport::fetchExternalResource(const std::string &url, return false; } namespace { // Strips a trailing "!important" (case-insensitive, any whitespace before // the "!") from a CSS declaration value, returning whether it was present. // libhtmlpp::CSSDeclaration's parser captures "!important" as ordinary // trailing text with no special handling, so left as-is it would end up as // literal corrupted text in a widget attribute meant to hold a plain value // (e.g. background_color="red !important"), and some rendering paths (e.g. // JS "el.style.width = value") silently drop the whole assignment once the // string contains this suffix instead of honoring just the priority hint. bool stripImportant(std::string &value) { size_t bang = value.rfind('!'); if (bang == std::string::npos) return false; size_t start = value.find_first_not_of(" \t\n\r", bang + 1); size_t end = value.find_last_not_of(" \t\n\r"); if (start == std::string::npos || end == std::string::npos || start > end) return false; std::string tail = value.substr(start, end - start + 1); std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { return std::tolower(c); }); if (tail != "important") return false; size_t valEnd = bang; while (valEnd > 0 && std::isspace(static_cast<unsigned char>(value[valEnd - 1]))) --valEnd; value.resize(valEnd); return true; } } // namespace std::map<std::string,std::string> blogi::htmlimport::declarationToMap( const libhtmlpp::CSSDeclaration &decl) { std::map<std::string,std::string> result; for (const auto &prop : decl.getProperties()) { result[prop.getName()] = prop.getValue(); std::string value = prop.getValue(); stripImportant(value); result[prop.getName()] = value; } return result; } Loading @@ -272,6 +306,15 @@ void blogi::htmlimport::mergeStylesheetRules( } } // 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; Loading Loading @@ -328,56 +371,78 @@ void blogi::htmlimport::mergeStylesheetRules( bool matches = false; // A bare tag name left over after stripping a combinator's // ancestor part (e.g. "[data-atom=header]>div" reduces to "div") // can't be verified here -- 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 on the rightmost compound (e.g. "div.foo") // is still specific enough to check below. bool bareTagAfterCombinator = hadCombinator && !matchSel.empty() && matchSel[0] != '#' && matchSel[0] != '.' && matchSel.find('.') == std::string::npos && matchSel.find('#') == std::string::npos; if (bareTagAfterCombinator) { // matches stays false } else if (!matchSel.empty() && matchSel[0] == '#') { if (!id.empty() && matchSel == "#" + id) matches = true; } else if (!matchSel.empty() && matchSel[0] == '.') { std::string clsSel = matchSel.substr(1); for (const auto &c : classes) { if (c == clsSel) { matches = true; break; } } // 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 { std::string selLower = matchSel; std::transform(selLower.begin(), selLower.end(), selLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (selLower == tag) matches = true; auto dotPos = matchSel.find('.'); if (!matches && dotPos != std::string::npos) { std::string tagPart = matchSel.substr(0, dotPos); std::string clsPart = matchSel.substr(dotPos + 1); std::transform(tagPart.begin(), tagPart.end(), tagPart.begin(), [](unsigned char c) { return std::tolower(c); }); if (tagPart == tag) { for (const auto &c : classes) { if (c == clsPart) { matches = true; break; } } 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; } } auto hashPos = matchSel.find('#'); if (!matches && hashPos != std::string::npos) { std::string tagPart = matchSel.substr(0, hashPos); std::string idPart = matchSel.substr(hashPos + 1); std::transform(tagPart.begin(), tagPart.end(), tagPart.begin(), [](unsigned char c) { return std::tolower(c); }); if (tagPart == tag && idPart == id) matches = true; // 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; } } Loading @@ -400,9 +465,21 @@ void blogi::htmlimport::mergeStylesheetRules( } } else { for (const auto &prop : rule.getDeclaration().getProperties()) { if (props.find(prop.getName()) == props.end()) { props[prop.getName()] = prop.getValue(); } 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); } } } Loading Loading @@ -1641,6 +1718,19 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, while (!searchStack.empty()) { libhtmlpp::Element *cur = searchStack.top(); searchStack.pop(); // Children discovered while walking this frame's sibling chain are // collected here and pushed in reverse at the end (same pattern as // htmlElementToWidgetXml's childFrames) instead of being pushed onto // searchStack the moment they're found. Pushing immediately would // have the stack's LIFO order process the LAST sibling's subtree // before earlier siblings' (e.g. <body>'s <style> collected before // <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). std::vector<libhtmlpp::Element*> childFrames; while (cur) { if (cur->getType() == libhtmlpp::ScriptEL) { libhtmlpp::ScriptElement *sel = static_cast<libhtmlpp::ScriptElement*>(cur); Loading Loading @@ -1766,11 +1856,15 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, } if (tagName != "style") { libhtmlpp::Element *child = hel->firstChild(); if (child) searchStack.push(child); if (child) childFrames.push_back(child); } } cur = cur->nextElement(); } for (auto it = childFrames.rbegin(); it != childFrames.rend(); ++it) { searchStack.push(*it); } } // Extract global CSS rules Loading Loading
editor/src/htmlimport.cpp +144 −50 Changes for editor/src/htmlimport.cpp: 144 added lines, 50 removed lines. Original line number Diff line number Diff line Loading @@ -243,13 +243,47 @@ bool blogi::htmlimport::fetchExternalResource(const std::string &url, return false; } namespace { // Strips a trailing "!important" (case-insensitive, any whitespace before // the "!") from a CSS declaration value, returning whether it was present. // libhtmlpp::CSSDeclaration's parser captures "!important" as ordinary // trailing text with no special handling, so left as-is it would end up as // literal corrupted text in a widget attribute meant to hold a plain value // (e.g. background_color="red !important"), and some rendering paths (e.g. // JS "el.style.width = value") silently drop the whole assignment once the // string contains this suffix instead of honoring just the priority hint. bool stripImportant(std::string &value) { size_t bang = value.rfind('!'); if (bang == std::string::npos) return false; size_t start = value.find_first_not_of(" \t\n\r", bang + 1); size_t end = value.find_last_not_of(" \t\n\r"); if (start == std::string::npos || end == std::string::npos || start > end) return false; std::string tail = value.substr(start, end - start + 1); std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { return std::tolower(c); }); if (tail != "important") return false; size_t valEnd = bang; while (valEnd > 0 && std::isspace(static_cast<unsigned char>(value[valEnd - 1]))) --valEnd; value.resize(valEnd); return true; } } // namespace std::map<std::string,std::string> blogi::htmlimport::declarationToMap( const libhtmlpp::CSSDeclaration &decl) { std::map<std::string,std::string> result; for (const auto &prop : decl.getProperties()) { result[prop.getName()] = prop.getValue(); std::string value = prop.getValue(); stripImportant(value); result[prop.getName()] = value; } return result; } Loading @@ -272,6 +306,15 @@ void blogi::htmlimport::mergeStylesheetRules( } } // 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; Loading Loading @@ -328,56 +371,78 @@ void blogi::htmlimport::mergeStylesheetRules( bool matches = false; // A bare tag name left over after stripping a combinator's // ancestor part (e.g. "[data-atom=header]>div" reduces to "div") // can't be verified here -- 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 on the rightmost compound (e.g. "div.foo") // is still specific enough to check below. bool bareTagAfterCombinator = hadCombinator && !matchSel.empty() && matchSel[0] != '#' && matchSel[0] != '.' && matchSel.find('.') == std::string::npos && matchSel.find('#') == std::string::npos; if (bareTagAfterCombinator) { // matches stays false } else if (!matchSel.empty() && matchSel[0] == '#') { if (!id.empty() && matchSel == "#" + id) matches = true; } else if (!matchSel.empty() && matchSel[0] == '.') { std::string clsSel = matchSel.substr(1); for (const auto &c : classes) { if (c == clsSel) { matches = true; break; } } // 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 { std::string selLower = matchSel; std::transform(selLower.begin(), selLower.end(), selLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (selLower == tag) matches = true; auto dotPos = matchSel.find('.'); if (!matches && dotPos != std::string::npos) { std::string tagPart = matchSel.substr(0, dotPos); std::string clsPart = matchSel.substr(dotPos + 1); std::transform(tagPart.begin(), tagPart.end(), tagPart.begin(), [](unsigned char c) { return std::tolower(c); }); if (tagPart == tag) { for (const auto &c : classes) { if (c == clsPart) { matches = true; break; } } 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; } } auto hashPos = matchSel.find('#'); if (!matches && hashPos != std::string::npos) { std::string tagPart = matchSel.substr(0, hashPos); std::string idPart = matchSel.substr(hashPos + 1); std::transform(tagPart.begin(), tagPart.end(), tagPart.begin(), [](unsigned char c) { return std::tolower(c); }); if (tagPart == tag && idPart == id) matches = true; // 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; } } Loading @@ -400,9 +465,21 @@ void blogi::htmlimport::mergeStylesheetRules( } } else { for (const auto &prop : rule.getDeclaration().getProperties()) { if (props.find(prop.getName()) == props.end()) { props[prop.getName()] = prop.getValue(); } 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); } } } Loading Loading @@ -1641,6 +1718,19 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, while (!searchStack.empty()) { libhtmlpp::Element *cur = searchStack.top(); searchStack.pop(); // Children discovered while walking this frame's sibling chain are // collected here and pushed in reverse at the end (same pattern as // htmlElementToWidgetXml's childFrames) instead of being pushed onto // searchStack the moment they're found. Pushing immediately would // have the stack's LIFO order process the LAST sibling's subtree // before earlier siblings' (e.g. <body>'s <style> collected before // <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). std::vector<libhtmlpp::Element*> childFrames; while (cur) { if (cur->getType() == libhtmlpp::ScriptEL) { libhtmlpp::ScriptElement *sel = static_cast<libhtmlpp::ScriptElement*>(cur); Loading Loading @@ -1766,11 +1856,15 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, } if (tagName != "style") { libhtmlpp::Element *child = hel->firstChild(); if (child) searchStack.push(child); if (child) childFrames.push_back(child); } } cur = cur->nextElement(); } for (auto it = childFrames.rbegin(); it != childFrames.rend(); ++it) { searchStack.push(*it); } } // Extract global CSS rules Loading