diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 35cf893..ba4265c 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -7725,7 +7725,7 @@ globalThis.scrollX = 0; globalThis.scrollY = 0; // is not harmless: Tailwind and other framework sheets use negative probes to // select legacy-browser fallbacks, which can replace their modern cascade. const _CSS_SUPPORTED_DECLARATIONS = new Set(( - "display width height min-width min-height max-width max-height box-sizing aspect-ratio " + + "display width height min-width min-height max-width max-height box-sizing aspect-ratio content " + "margin margin-top margin-right margin-bottom margin-left margin-inline margin-inline-start " + "margin-inline-end margin-block margin-block-start margin-block-end padding padding-top " + "padding-right padding-bottom padding-left padding-inline padding-inline-start padding-inline-end " + @@ -7735,7 +7735,7 @@ const _CSS_SUPPORTED_DECLARATIONS = new Set(( "background-position background-clip -webkit-background-clip mask-image -webkit-mask-image " + "mask-size -webkit-mask-size mask-repeat -webkit-mask-repeat color -webkit-text-fill-color fill " + "stroke stroke-width border-color font-size font font-weight font-family font-style text-align " + - "text-transform text-decoration text-decoration-line line-height align-items justify-items " + + "text-transform text-decoration text-decoration-line line-height white-space overflow-wrap word-wrap word-break text-wrap text-wrap-style align-items justify-items " + "place-items align-self justify-self place-self align-content justify-content place-content " + "flex-direction flex-wrap flex-grow flex-shrink flex-basis flex order position float object-fit " + "top right bottom left inset overflow overflow-x overflow-y scrollbar-gutter visibility opacity animation " + @@ -7839,14 +7839,18 @@ function _cssTopLevelComma(text) { function _cssSupportsDeclaration(name, value) { name = name.trim().toLowerCase(); value = value.trim(); - if (!value) return false; + if (typeof Deno.core.ops.op_css_supports === "function") { + try { return !!Deno.core.ops.op_css_supports(name, value); } + catch (_) { return false; } + } + if (!value || _cssHasInvalidSupportsValueSyntax(value)) return false; if (name.startsWith("--")) return name.length > 2; if (!_CSS_SUPPORTED_DECLARATIONS.has(name)) return false; const lower = value.toLowerCase(); if (["initial", "inherit", "unset", "revert", "revert-layer"].includes(lower)) return true; if (name === "display") { return ["none", "flex", "inline-flex", "inline", "inline-block", "grid", - "inline-grid", "block", "flow-root", "contents"].includes(lower); + "inline-grid", "block", "flow-root", "table", "inline-table", "contents"].includes(lower); } if (name === "position") { return ["static", "relative", "absolute", "fixed", "sticky"].includes(lower); @@ -7860,10 +7864,190 @@ function _cssSupportsDeclaration(name, value) { if (name === "scrollbar-gutter") { return lower === "auto" || lower === "stable" || lower === "stable both-edges"; } + if (name === "white-space") { + return ["normal", "nowrap", "pre", "pre-wrap", "pre-line", "break-spaces"].includes(lower); + } + if (name === "overflow-wrap" || name === "word-wrap") { + return ["normal", "break-word", "anywhere"].includes(lower); + } + if (name === "word-break") { + return ["normal", "break-all", "keep-all", "break-word"].includes(lower); + } + if (name === "text-wrap") { + return ["auto", "wrap", "balance", "wrap balance", "balance wrap"].includes(lower); + } + if (name === "text-wrap-style") return lower === "auto" || lower === "balance"; + if (["filter", "backdrop-filter", "-webkit-backdrop-filter", "perspective"].includes(name)) { + return lower === "none"; + } + if (name === "contain") return lower === "none"; + if (name === "content-visibility") return lower === "visible"; + if (name === "content") return _cssSupportsContent(value); + if (["width", "height", "min-width", "min-height", "max-width", "max-height", "flex-basis"].includes(name)) { + return _cssSupportsDimension(value, true) || (name === "width" && lower === "fit-content"); + } + if (/^(?:margin(?:-(?:top|right|bottom|left|inline|inline-start|inline-end|block|block-start|block-end))?|padding(?:-(?:top|right|bottom|left|inline|inline-start|inline-end|block|block-start|block-end))?|inset(?:-(?:inline|inline-start|inline-end|block|block-start|block-end))?|top|right|bottom|left)$/.test(name)) { + const allowAuto = name.startsWith("margin") || name === "top" || name === "right" || name === "bottom" || name === "left" || name.startsWith("inset"); + const parts = _cssSplitWhitespace(value); + const max = /^(?:margin|padding|inset)$/.test(name) ? 4 : (/(?:inline|block)$/.test(name) ? 2 : 1); + return parts.length > 0 && parts.length <= max && parts.every((part) => _cssSupportsDimension(part, allowAuto)); + } + if (["align-items", "justify-items", "align-self", "justify-self"].includes(name)) { + return _cssSupportsSelfAlignment(lower); + } + if (name === "align-content" || name === "justify-content") { + return _cssSupportsContentAlignment(lower) || (name === "justify-content" && ["left", "right"].includes(lower)); + } + if (name === "flex-direction") return ["row", "row-reverse", "column", "column-reverse"].includes(lower); + if (name === "flex-wrap") return ["nowrap", "wrap", "wrap-reverse"].includes(lower); + if (name === "flex-grow" || name === "flex-shrink") { + return /^(?:\d+(?:\.\d*)?|\.\d+)$/.test(lower); + } + if (name === "order") return /^[-+]?\d+$/.test(lower); + if (name === "opacity") return /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(lower); + if (name === "z-index") return lower === "auto" || /^[-+]?\d+$/.test(lower); if (["color", "-webkit-text-fill-color", "background-color", "border-color"].includes(name)) { return _cssSupportsColor(value); } - return true; + return false; +} + +function _cssHasInvalidSupportsValueSyntax(value) { + let depth = 0, quote = ""; + for (let i = 0; i < value.length; i++) { + const character = value[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "\\") { i++; continue; } + if (character === "'" || character === '"') quote = character; + else if (character === "(" || character === "[") depth++; + else if (character === ")" || character === "]") { + if (--depth < 0) return true; + } else if (depth === 0 && /[;{}]/.test(character)) return true; + else if (depth === 0 && character === "!" && /^\s*important\b/i.test(value.slice(i + 1))) return true; + } + return depth !== 0 || !!quote; +} + +function _cssSplitWhitespace(value) { + const values = [], split = _cssSplitTopLevel(value, " "); + if (split) return split; + let depth = 0, quote = "", start = -1; + for (let i = 0; i <= value.length; i++) { + const character = value[i] || " "; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + } else if (character === "'" || character === '"') quote = character; + else if (character === "(") depth++; + else if (character === ")") depth--; + if (/\s/.test(character) && depth === 0 && !quote) { + if (start >= 0) values.push(value.slice(start, i)); + start = -1; + } else if (start < 0) start = i; + } + return values; +} + +function _cssSupportsDimension(value, allowAuto) { + const lower = value.trim().toLowerCase(); + if (allowAuto && lower === "auto") return true; + if (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|pt|em|ex|rem|vw|vh|dvw|dvh|svw|svh|lvw|lvh|vmin|vmax|%)$/i.test(lower)) return true; + if (/^[-+]?0(?:\.0*)?$/.test(lower)) return true; + return /^(?:calc|min|max|clamp|var)\(.+\)$/i.test(lower); +} + +function _cssSupportsSelfAlignment(value) { + return /^(?:auto|normal|stretch|baseline|first baseline|center|(?:safe |unsafe )?(?:start|end|self-start|self-end|flex-start|flex-end))$/.test(value); +} + +function _cssSupportsContentAlignment(value) { + return /^(?:normal|stretch|baseline|first baseline|space-between|space-around|space-evenly|(?:safe |unsafe )?(?:start|end|flex-start|flex-end|center))$/.test(value); +} + +function _cssSupportsContent(value) { + const lower = value.trim().toLowerCase(); + if (lower === "none" || lower === "normal" || _cssSupportsSingleUrl(value)) return true; + let rest = value.trim(), found = false; + while (rest) { + rest = rest.trimStart(); + if (rest[0] === "'" || rest[0] === '"') { + const quote = rest[0]; + let end = 1; + for (; end < rest.length; end++) { + if (rest[end] === "\\") end++; + else if (rest[end] === quote) break; + } + if (end >= rest.length) return false; + rest = rest.slice(end + 1); + found = true; + continue; + } + const keyword = /^(?:open-quote|close-quote|no-open-quote|no-close-quote)\b/i.exec(rest); + if (keyword) { + rest = rest.slice(keyword[0].length); + found = true; + continue; + } + const fn = /^(attr|counter|counters)\(/i.exec(rest); + if (!fn) return false; + let depth = 0, quote = "", end = -1; + for (let i = fn[1].length; i < rest.length; i++) { + const character = rest[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + } else if (character === "'" || character === '"') quote = character; + else if (character === "(") depth++; + else if (character === ")" && --depth === 0) { end = i; break; } + } + const argumentsText = rest.slice(fn[0].length, end).trim(); + if (end < 0 || !_cssSupportsContentFunction(fn[1].toLowerCase(), argumentsText)) return false; + rest = rest.slice(end + 1); + found = true; + } + return found; +} + +function _cssSupportsSingleUrl(value) { + value = value.trim(); + if (!/^url\(/i.test(value) || !value.endsWith(")")) return false; + let depth = 0, quote = ""; + for (let i = 0; i < value.length; i++) { + const character = value[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "\\") { i++; continue; } + if (character === "'" || character === '"') quote = character; + else if (character === "(") depth++; + else if (character === ")" && --depth === 0) { + return i === value.length - 1 && value.slice(4, i).trim().length > 0; + } + } + return false; +} + +function _cssSupportsContentFunction(name, argumentsText) { + const argumentsList = _cssSplitTopLevel(argumentsText, ",") || [argumentsText]; + const ident = (value) => /^[a-z0-9_\\-]+$/i.test(value.trim()); + const counterStyle = (value) => /^(?:decimal|decimal-leading-zero|lower-alpha|lower-latin|upper-alpha|upper-latin|lower-roman|upper-roman)$/i.test(value.trim()); + if (name === "attr") return argumentsList.length === 1 && ident(argumentsList[0].trim().split(/\s+/)[0]); + if (name === "counter") { + return argumentsList.length >= 1 && argumentsList.length <= 2 && ident(argumentsList[0]) && + (argumentsList.length === 1 || counterStyle(argumentsList[1])); + } + if (name === "counters") { + return argumentsList.length >= 2 && argumentsList.length <= 3 && ident(argumentsList[0]) && + /^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')$/.test(argumentsList[1].trim()) && + (argumentsList.length === 2 || counterStyle(argumentsList[2])); + } + return false; } // Return the contents only when one pair of parentheses encloses the complete @@ -8001,24 +8185,61 @@ function _cssSupportsSelector(selector) { }); } -function _cssSupportsCondition(condition) { +function _cssBalancedSupportsSyntax(condition) { + const stack = []; + let quote = ""; + for (let i = 0; i < condition.length; i++) { + const character = condition[i]; + if (quote) { + if (character === "\\") i++; + else if (character === quote) quote = ""; + continue; + } + if (character === "\\") { i++; continue; } + if (character === "'" || character === '"') quote = character; + else if (character === "(") stack.push(")"); + else if (character === "[") stack.push("]"); + else if ((character === ")" || character === "]") && stack.pop() !== character) return false; + } + return !quote && stack.length === 0; +} + +// `null` means invalid syntax, which is distinct from a valid false leaf. +// In particular `not ` must remain false rather than flipping true. +function _cssSupportsConditionResult(condition) { condition = condition.trim(); - if (!condition) return false; + if (!condition || !_cssBalancedSupportsSyntax(condition)) return null; const grouped = _cssEnclosingGroup(condition); - if (grouped !== null) return _cssSupportsCondition(grouped); - if (/^not\s/i.test(condition)) return !_cssSupportsCondition(condition.slice(3).trim()); + if (grouped !== null) return _cssSupportsConditionResult(grouped); + if (/^not\s/i.test(condition)) { + const result = _cssSupportsConditionResult(condition.slice(3).trim()); + return result === null ? null : !result; + } const orParts = _cssSplitTopLevel(condition, "or"); - if (orParts) return orParts.some(_cssSupportsCondition); const andParts = _cssSplitTopLevel(condition, "and"); - if (andParts) return andParts.every(_cssSupportsCondition); + if (orParts && andParts) return null; + if (orParts) { + const results = orParts.map(_cssSupportsConditionResult); + return results.includes(null) ? null : results.some(Boolean); + } + if (andParts) { + const results = andParts.map(_cssSupportsConditionResult); + return results.includes(null) ? null : results.every(Boolean); + } if (/^selector\(/i.test(condition) && condition.endsWith(")")) { return _cssSupportsSelector(condition.slice(condition.indexOf("(") + 1, -1)); } const colon = condition.indexOf(":"); - if (colon < 0) return false; + if (colon < 0) { + return /^[a-z_-][a-z0-9_-]*\([\s\S]*\)$/i.test(condition) ? false : null; + } return _cssSupportsDeclaration(condition.slice(0, colon), condition.slice(colon + 1)); } +function _cssSupportsCondition(condition) { + return _cssSupportsConditionResult(condition) === true; +} + globalThis.CSS = { supports(prop, value){ try { diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 69f0cfa..59a95c7 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -2457,6 +2457,7 @@ pub fn build_extension() -> Extension { ops.push(op_image_metadata()); ops.push(op_layout_geometry()); ops.push(op_computed_style()); + ops.push(op_css_supports()); ops.push(op_layout_metrics()); ops.push(op_scroll_offset()); ops.push(op_scroll_to()); @@ -2703,6 +2704,15 @@ fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String { serde_json::Value::Object(object).to_string() } +/// Use the renderer's declaration parser as the single feature-query source +/// of truth. Keeping this bridge synchronous and state-free makes the common +/// two-argument `CSS.supports()` overload a single native call. +#[cfg(feature = "render")] +#[op2(fast)] +fn op_css_supports(#[string] name: &str, #[string] value: &str) -> bool { + obscura_render::style::supports_declaration(name, value) +} + /// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in /// render builds; default scraping builds retain their deliberately unbounded /// synthetic scrolling behavior. diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 8ef5981..a1c2eaa 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -3086,13 +3086,19 @@ mod tests { CSS.supports("color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"), CSS.supports("(color:light-dark(red, light-dark(white, black)))"), CSS.supports("color", "light-dark(red)"), - CSS.supports("color", "light-dark(red, rgb(1, 2, 3)") + CSS.supports("color", "light-dark(red, rgb(1, 2, 3)"), + CSS.supports("border", "2px dashed red"), + CSS.supports("border-width", "10%"), + CSS.supports("word-break", "break-all"), + CSS.supports("filter", "blur(2px)"), + CSS.supports("content", "attr(data-label)"), + CSS.supports("display", "grid;") ])"#, ) .unwrap(); assert_eq!( result, - serde_json::json!("[false,false,false,true,false,true,true,true,false,false,false,true,false,true,true,false,false]") + serde_json::json!("[false,false,false,false,false,true,true,true,false,false,false,true,false,true,true,false,false,true,false,true,false,true,false]") ); } diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 1729a49..4ae95f5 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -2517,13 +2517,13 @@ pub(crate) fn supports_condition_applies(condition: &str) -> bool { .or_else(|| condition.strip_prefix("supports")) .unwrap_or(condition) .trim(); - eval_supports_condition(condition) + eval_supports_condition(condition).unwrap_or(false) } -fn eval_supports_condition(condition: &str) -> bool { +fn eval_supports_condition(condition: &str) -> Option { let condition = condition.trim(); - if condition.is_empty() { - return false; + if condition.is_empty() || !balanced_supports_syntax(condition) { + return None; } if let Some(inner) = enclosing_parenthesized(condition) { return eval_supports_condition(inner); @@ -2536,23 +2536,76 @@ fn eval_supports_condition(condition: &str) -> bool { .get(3) .is_some_and(u8::is_ascii_whitespace) { - return !eval_supports_condition(condition[3..].trim()); + return eval_supports_condition(condition[3..].trim()).map(|result| !result); } - if let Some(parts) = split_supports_operator(condition, "or") { - return parts.into_iter().any(eval_supports_condition); + let or_parts = split_supports_operator(condition, "or"); + let and_parts = split_supports_operator(condition, "and"); + // CSS Conditional Rules does not permit `and` and `or` at the same + // nesting level. Treat the entire condition as invalid rather than + // inventing JavaScript-like precedence. + if or_parts.is_some() && and_parts.is_some() { + return None; } - if let Some(parts) = split_supports_operator(condition, "and") { - return parts.into_iter().all(eval_supports_condition); + if let Some(parts) = or_parts { + let results = parts + .into_iter() + .map(eval_supports_condition) + .collect::>>()?; + return Some(results.into_iter().any(|result| result)); + } + if let Some(parts) = and_parts { + let results = parts + .into_iter() + .map(eval_supports_condition) + .collect::>>()?; + return Some(results.into_iter().all(|result| result)); } let lower = condition.to_ascii_lowercase(); if lower.starts_with("selector(") && condition.ends_with(')') { let inner = &condition["selector(".len()..condition.len() - 1]; - return obscura_dom::selector::parse_selector(inner.trim()).is_ok(); + return Some(obscura_dom::selector::parse_selector(inner.trim()).is_ok()); } let Some((name, value)) = condition.split_once(':') else { - return false; + // A syntactically balanced unknown functional condition is a valid + // general-enclosed leaf whose result is false. Everything else is a + // malformed condition, important for `not`: invalid syntax must not + // become true merely because it was negated. + return condition + .find('(') + .filter(|index| *index > 0 && condition.ends_with(')')) + .map(|_| false); }; - crate::style::supports_declaration(name, value) + Some(crate::style::supports_declaration(name, value)) +} + +fn balanced_supports_syntax(condition: &str) -> bool { + let mut stack = Vec::new(); + let mut quote = None; + let mut escaped = false; + for character in condition.chars() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if let Some(active) = quote { + if character == active { + quote = None; + } + continue; + } + match character { + '\'' | '"' => quote = Some(character), + '(' => stack.push(')'), + '[' => stack.push(']'), + ')' | ']' if stack.pop() != Some(character) => return false, + _ => {} + } + } + !escaped && quote.is_none() && stack.is_empty() } /// Return the contents when one outer parenthesis pair encloses the complete @@ -4671,6 +4724,21 @@ mod tests { ); } + #[test] + fn supports_conditions_reject_invalid_boolean_grammar() { + assert!(supports_condition_applies("(display:grid) and (word-break:break-all)")); + assert!(supports_condition_applies("(display:grid) or (unknown:value)")); + assert!(!supports_condition_applies( + "(display:grid) and (word-break:break-all) or (display:flex)" + )); + assert!(!supports_condition_applies( + "not ((display:grid) and (word-break:break-all) or (display:flex))" + )); + assert!(!supports_condition_applies("not ()")); + assert!(!supports_condition_applies("(display:grid;)")); + assert!(!supports_condition_applies("not (display:grid")); + } + #[test] fn supports_polygon_clip_path_activates_only_painted_shape_subset() { let css = "@supports (clip-path:polygon(0 0,100% 0,50% 100%)){\ diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 08db52a..ca4a1e8 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -1625,10 +1625,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { /// modern framework sheets use negative feature probes to isolate legacy /// browser fallbacks, and activating those fallbacks corrupts the modern /// cascade. -pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { +/// Return whether the renderer accepts and faithfully implements a CSS +/// declaration. This is also exposed to the JavaScript runtime so +/// `CSS.supports(property, value)` and stylesheet `@supports` cannot drift. +pub fn supports_declaration(name: &str, value: &str) -> bool { let name = name.trim().to_ascii_lowercase(); let value = value.trim(); - if value.is_empty() { + if value.is_empty() || has_invalid_supports_value_syntax(value) { return false; } if name.starts_with("--") { @@ -1715,6 +1718,7 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "mask-repeat" | "-webkit-mask-repeat" | "color" + | "content" | "-webkit-text-fill-color" | "fill" | "stroke" @@ -1860,6 +1864,8 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "inline-grid" | "block" | "flow-root" + | "table" + | "inline-table" | "contents" ), "position" => matches!( @@ -1908,6 +1914,16 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { "clip-path" | "-webkit-clip-path" => { value.eq_ignore_ascii_case("none") || parse_clip_path_polygon(value).is_some() } + // These properties currently participate only in containing-block + // bookkeeping. Advertising an effect that is not painted is worse + // than a conservative false result because feature queries commonly + // use them to select the only visible implementation of an effect. + "filter" | "backdrop-filter" | "-webkit-backdrop-filter" | "perspective" => { + value.eq_ignore_ascii_case("none") + } + "contain" => value.eq_ignore_ascii_case("none"), + "content-visibility" => value.eq_ignore_ascii_case("visible"), + "content" => supports_content_value(value), "animation-fill-mode" => parse_animation_fill_mode(value).is_some(), "animation-play-state" => parse_animation_play_state(value).is_some(), "float" => matches!( @@ -1997,10 +2013,380 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { ) }) } - _ => true, + _ => supports_conservative_known_value(&name, value), } } +/// CSS.supports() parses a declaration value, not an entire declaration list. +/// A top-level semicolon, brace, or `!important` therefore makes the overload +/// invalid. Delimiters inside strings or functions remain ordinary tokens. +fn has_invalid_supports_value_syntax(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut depth = 0i32; + let mut quote = None; + let mut index = 0usize; + while index < bytes.len() { + let byte = bytes[index]; + if let Some(active) = quote { + if byte == b'\\' { + index += 2; + continue; + } + if byte == active { + quote = None; + } + index += 1; + continue; + } + match byte { + b'\\' => index += 1, + b'\'' | b'"' => quote = Some(byte), + b'(' | b'[' => depth += 1, + b')' | b']' => { + depth -= 1; + if depth < 0 { + return true; + } + } + b';' | b'{' | b'}' if depth == 0 => return true, + b'!' if depth == 0 + && value[index + 1..] + .trim_start() + .get(..9) + .is_some_and(|word| word.eq_ignore_ascii_case("important")) => + { + return true; + } + _ => {} + } + index += 1; + } + depth != 0 || quote.is_some() +} + +fn supports_content_value(value: &str) -> bool { + let lower = value.trim().to_ascii_lowercase(); + if matches!(lower.as_str(), "none" | "normal") || supports_single_url(value) { + return true; + } + let mut rest = value.trim(); + let mut found = false; + while !rest.is_empty() { + rest = rest.trim_start(); + let Some(first) = rest.chars().next() else { break }; + if matches!(first, '\'' | '"') { + let mut escaped = false; + let mut end = None; + for (offset, character) in rest[first.len_utf8()..].char_indices() { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == first { + end = Some(first.len_utf8() + offset + first.len_utf8()); + break; + } + } + let Some(end) = end else { return false }; + rest = &rest[end..]; + found = true; + continue; + } + let name_end = rest + .char_indices() + .find_map(|(index, ch)| (!ch.is_alphanumeric() && !matches!(ch, '-' | '_' | '\\')).then_some(index)) + .unwrap_or(rest.len()); + let name = rest[..name_end].to_ascii_lowercase(); + let tail = rest[name_end..].trim_start(); + if matches!( + name.as_str(), + "open-quote" | "close-quote" | "no-open-quote" | "no-close-quote" + ) { + rest = tail; + found = true; + continue; + } + if !matches!(name.as_str(), "attr" | "counter" | "counters") || !tail.starts_with('(') { + return false; + } + let mut depth = 0i32; + let mut quote = None; + let mut escaped = false; + let mut end = None; + for (offset, character) in tail.char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if let Some(active) = quote { + if character == active { + quote = None; + } + continue; + } + match character { + '\'' | '"' => quote = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = Some(offset + 1); + break; + } + } + _ => {} + } + } + let Some(end) = end else { return false }; + let arguments = tail[1..end - 1].trim(); + if !supports_content_function(&name, arguments) { + return false; + } + rest = &tail[end..]; + found = true; + } + found +} + +fn supports_single_url(value: &str) -> bool { + let value = value.trim(); + if !value + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("url(")) + || !value.ends_with(')') + { + return false; + } + let mut depth = 0i32; + let mut quote = None; + let mut escaped = false; + for (index, character) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if let Some(active) = quote { + if character == active { + quote = None; + } + continue; + } + match character { + '\'' | '"' => quote = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return index + character.len_utf8() == value.len() + && parse_url(value).is_some(); + } + } + _ => {} + } + } + false +} + +fn supports_content_function(name: &str, arguments: &str) -> bool { + let arguments = split_top_level(arguments, ','); + let ident = |value: &str| { + let value = value.trim(); + !value.is_empty() + && value + .chars() + .all(|character| character.is_alphanumeric() || matches!(character, '-' | '_' | '\\')) + }; + let counter_style = |value: &str| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "decimal" + | "decimal-leading-zero" + | "lower-alpha" + | "lower-latin" + | "upper-alpha" + | "upper-latin" + | "lower-roman" + | "upper-roman" + ) + }; + match name { + "attr" => arguments.len() == 1 + && arguments[0] + .split_whitespace() + .next() + .is_some_and(ident), + "counter" => (1..=2).contains(&arguments.len()) + && ident(arguments[0]) + && arguments.get(1).is_none_or(|value| counter_style(value)), + "counters" => (2..=3).contains(&arguments.len()) + && ident(arguments[0]) + && { + let separator = arguments[1].trim(); + separator.len() >= 2 + && matches!(separator.as_bytes()[0], b'\'' | b'"') + && separator.as_bytes().last() == separator.as_bytes().first() + } + && arguments.get(2).is_none_or(|value| counter_style(value)), + _ => false, + } +} + +fn supports_conservative_known_value(name: &str, value: &str) -> bool { + let lower = value.trim().to_ascii_lowercase(); + let finite_number = |input: &str| { + input + .trim() + .parse::() + .ok() + .is_some_and(|number| number.is_finite()) + }; + let dimension = |input: &str, auto: bool| { + let input = input.trim(); + (auto && input.eq_ignore_ascii_case("auto")) + || (!matches!(dimension_value(input), crate::Dimension::Auto) + && resolve_contextual_length(input, 16.0, 16.0, 1.0, 1.0, 100.0).is_some()) + || (input.contains('(') + && resolve_contextual_length(input, 16.0, 16.0, 1.0, 1.0, 100.0).is_some()) + }; + let dimensions = |input: &str, auto: bool, max: usize| { + let tokens = split_ws_paren(input); + !tokens.is_empty() && tokens.len() <= max && tokens.iter().all(|token| dimension(token, auto)) + }; + match name { + "width" => lower == "fit-content" || dimension(value, true), + "height" | "min-width" | "min-height" | "max-width" | "max-height" | "flex-basis" => { + dimension(value, true) + } + "margin" | "margin-inline" | "margin-block" => dimensions(value, true, 4), + "margin-top" | "margin-right" | "margin-bottom" | "margin-left" + | "margin-inline-start" | "margin-inline-end" | "margin-block-start" + | "margin-block-end" => dimension(value, true), + "padding" | "padding-inline" | "padding-block" | "inset" => dimensions(value, false, 4), + "padding-top" | "padding-right" | "padding-bottom" | "padding-left" + | "padding-inline-start" | "padding-inline-end" | "padding-block-start" + | "padding-block-end" => dimension(value, false), + "top" | "right" | "bottom" | "left" | "inset-inline" | "inset-block" => { + dimensions(value, true, 2) + } + "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => { + dimension(value, true) + } + "aspect-ratio" => lower == "auto" || parse_aspect_ratio(value).is_some(), + "align-items" | "justify-items" | "align-self" | "justify-self" => { + self_alignment_value(value).is_some() + } + "place-items" | "place-self" => self_alignment_pair(value).is_some(), + "align-content" => content_alignment_value(value).is_some(), + "justify-content" => matches!(lower.as_str(), "left" | "right") || content_alignment_value(value).is_some(), + "place-content" => content_alignment_pair(value).is_some(), + "flex-direction" => matches!(lower.as_str(), "row" | "row-reverse" | "column" | "column-reverse"), + "flex-wrap" => matches!(lower.as_str(), "nowrap" | "wrap" | "wrap-reverse"), + "flex-grow" | "flex-shrink" => finite_number(value) && value.trim().parse::().is_ok_and(|number| number >= 0.0), + "flex" => supports_flex_value(value), + "order" => value.trim().parse::().is_ok(), + "opacity" => finite_number(value), + "z-index" => lower == "auto" || value.trim().parse::().is_ok(), + "clear" => matches!(lower.as_str(), "none" | "left" | "right" | "both" | "inline-start" | "inline-end"), + "vertical-align" => matches!(lower.as_str(), "top" | "baseline" | "text-top" | "middle" | "bottom" | "text-bottom"), + "border-collapse" => matches!(lower.as_str(), "collapse" | "separate"), + "border-spacing" => dimensions(value, false, 2), + "column-count" | "-webkit-column-count" => lower == "auto" || parse_column_count(value).is_some(), + "columns" | "-webkit-columns" => split_ws_paren(value).into_iter().any(|token| parse_column_count(token).is_some()), + "break-inside" => matches!(lower.as_str(), "auto" | "avoid" | "avoid-column"), + "-webkit-column-break-inside" => matches!(lower.as_str(), "auto" | "avoid"), + "grid-auto-flow" => { + let tokens = lower.split_whitespace().collect::>(); + !tokens.is_empty() && tokens.len() <= 2 && tokens.iter().all(|token| matches!(*token, "row" | "column" | "dense")) + } + "grid-template-columns" | "grid-template-rows" => { + let (tracks, _) = parse_track_list_named(value); + !tracks.is_empty() || lower.starts_with("subgrid") + } + "grid-template-areas" => !parse_grid_areas(value).is_empty() || lower == "none", + "grid-column" | "grid-row" => split_top_level(value, '/').into_iter().all(|part| parse_grid_line_kind(part.trim()).is_some()), + "grid-column-start" | "grid-column-end" | "grid-row-start" | "grid-row-end" => parse_grid_line_kind(value).is_some(), + "transform-origin" => parse_transform_origin(value).is_some(), + "translate" => lower == "none" || dimensions(value, false, 3), + "scale" => lower == "none" || { + let values = value.split_whitespace().collect::>(); + !values.is_empty() && values.len() <= 2 && values.iter().all(|value| scale_number(value).is_some()) + }, + "transform" => supports_transform_value(value), + "background-color" | "color" | "-webkit-text-fill-color" => parse_color(value).is_some(), + "background-image" | "mask-image" | "-webkit-mask-image" => { + lower == "none" || parse_url(value).is_some() || !parse_background_gradient_layers(value, false).is_empty() + } + "background-repeat" | "mask-repeat" | "-webkit-mask-repeat" => parse_image_repeat(value).is_some(), + "background-size" | "mask-size" | "-webkit-mask-size" => { + matches!(lower.as_str(), "auto" | "cover" | "contain") || parse_background_size(value).is_some() + } + "background-clip" | "-webkit-background-clip" => matches!(lower.as_str(), "border-box" | "padding-box" | "content-box" | "text"), + "font-size" => is_font_size_token(value), + "font-weight" => specified_font_weight(value).is_some(), + "font-family" => !value.trim().is_empty(), + "font-style" => lower == "normal" || lower == "italic" || lower.starts_with("oblique"), + "text-align" => matches!(lower.as_str(), "left" | "right" | "start" | "end" | "center" | "justify"), + "text-transform" => matches!(lower.as_str(), "none" | "uppercase" | "lowercase" | "capitalize"), + "text-decoration" | "text-decoration-line" => lower.split_whitespace().all(|token| matches!(token, "none" | "underline")), + "line-height" => lower == "normal" || finite_number(value) || dimension(value, false), + "gap" | "grid-gap" => dimensions(value, false, 2), + "row-gap" | "grid-row-gap" | "column-gap" | "grid-column-gap" | "-webkit-column-gap" => lower == "normal" || dimension(value, false), + "counter-reset" | "counter-increment" | "counter-set" => parse_counter_directives(value, 0).is_some(), + "list-style-type" => list_style_keyword(value.trim()).is_some(), + "list-style" => value.split_whitespace().any(|token| list_style_keyword(token).is_some()), + "animation" | "animation-name" => !value.trim().is_empty(), + "background" | "font" | "grid-template" | "grid" | "box-shadow" | "-webkit-box-shadow" | "fill" | "stroke" | "stroke-width" | "letter-spacing" | "will-change" => false, + _ => false, + } +} + +fn supports_flex_value(value: &str) -> bool { + let lower = value.trim().to_ascii_lowercase(); + if matches!(lower.as_str(), "none" | "auto" | "initial") { + return true; + } + let tokens = split_ws_paren(value); + if tokens.is_empty() || tokens.len() > 3 { + return false; + } + let mut numbers = 0usize; + for token in tokens { + if token.parse::().ok().is_some_and(|number| number.is_finite() && number >= 0.0) { + numbers += 1; + } else if matches!(dimension_value(token), crate::Dimension::Auto) && !token.eq_ignore_ascii_case("auto") { + return false; + } + } + numbers <= 2 +} + +fn supports_transform_value(value: &str) -> bool { + if value.trim().eq_ignore_ascii_case("none") { + return true; + } + let functions = transform_functions(value); + !functions.is_empty() && functions.into_iter().all(|(name, arguments)| { + let values = arguments.split(',').map(str::trim).filter(|value| !value.is_empty()).collect::>(); + match name.to_ascii_lowercase().as_str() { + "translate" => (1..=2).contains(&values.len()) && values.iter().all(|value| !matches!(dimension_value(value), crate::Dimension::Auto)), + "translate3d" => values.len() == 3 && values.iter().all(|value| !matches!(dimension_value(value), crate::Dimension::Auto)), + "translatex" | "translatey" => values.len() == 1 && !matches!(dimension_value(values[0]), crate::Dimension::Auto), + "scale" => (1..=2).contains(&values.len()) && values.iter().all(|value| scale_number(value).is_some()), + "scalex" | "scaley" => values.len() == 1 && scale_number(values[0]).is_some(), + "rotate" | "rotatex" | "rotatey" | "rotatez" => values.len() == 1 && angle_degrees(values[0]).is_some(), + _ => false, + } + }) +} + fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) { let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); if first.is_empty() { @@ -6370,6 +6756,35 @@ fn split_ws_paren(s: &str) -> Vec<&str> { mod tests { use super::*; + #[test] + fn supports_reports_only_implemented_value_subsets() { + for value in ["normal", "break-all", "keep-all", "break-word"] { + assert!(supports_declaration("word-break", value), "{value}"); + } + for property in ["filter", "backdrop-filter", "-webkit-backdrop-filter"] { + assert!(supports_declaration(property, "none"), "{property}"); + assert!(!supports_declaration(property, "blur(2px)"), "{property}"); + } + assert!(!supports_declaration("perspective", "800px")); + assert!(!supports_declaration("contain", "paint")); + assert!(!supports_declaration("content-visibility", "auto")); + for content in [ + "none", + "\"new\"", + "attr(data-label)", + "counter(item) '. '", + "url(icon.svg)", + ] { + assert!(supports_declaration("content", content), "{content}"); + } + assert!(!supports_declaration("content", "unknown-function(x)")); + assert!(!supports_declaration("content", "counter(item, banana)")); + assert!(!supports_declaration("content", "url(icon.svg) garbage()")); + assert!(!supports_declaration("display", "grid;")); + assert!(!supports_declaration("color", "red !important")); + assert!(!supports_declaration("display", "banana")); + } + #[test] fn clip_path_polygon_supports_only_geometry_it_can_resolve() { assert!(supports_declaration(