Files
obscura/crates/obscura-render/src/style.rs
T

7337 lines
273 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Computed-style lite: parse inline CSS declarations plus a small UA default
//! sheet into the layout-relevant subset of [`crate::LayoutStyle`].
//!
//! This is deliberately not a full CSS cascade. It handles the properties that
//! influence phase-1 box layout (display, width/height, margin, padding, border
//! width) from inline `style="..."` attributes, layered on a tiny built-in UA
//! sheet. A real cascade with selector matching and `cssparser` can harden this
//! later; for inline-style attributes a compact tokenizer is enough and keeps
//! the crate dependency-light.
use crate::{Display, Edges, LayoutStyle};
use std::collections::BTreeMap;
/// Compute the layout-relevant style for an element: UA defaults for its tag,
/// overridden by its inline `style="..."` declarations.
pub fn compute_style(tag: &str, inline_css: Option<&str>) -> LayoutStyle {
let mut style = ua_style(tag);
if let Some(css) = inline_css {
apply_inline(&mut style, css);
}
style
}
/// Built-in UA defaults. Inline elements currently map to block layout; real
/// inline/text layout arrives with the text/paint phase.
pub fn ua_style(tag: &str) -> LayoutStyle {
let mut style = LayoutStyle::default();
if tag == "b" || tag == "strong" {
style.font_weight = Some("bold".into());
}
style.display = match tag {
// Phrasing / inline-level content defaults to inline so a paragraph
// that mixes these with text stays one inline formatting context that
// cosmic-text can shape and wrap as a whole, instead of each element
// becoming its own block box (which forces the flex word-promotion
// fallback and its fragile one-word-per-line wrapping). Author CSS
// (e.g. `code{display:block}`) still overrides this in the cascade.
"span" | "a" | "b" | "i" | "strong" | "em" | "font" | "code" | "small"
| "sub" | "sup" | "mark" | "abbr" | "cite" | "var" | "dfn" | "kbd"
| "samp" | "q" | "time" | "s" | "u" | "del" | "ins" | "tt" | "big"
| "bdi" | "bdo" | "wbr" | "data" | "output" | "label" | "ruby" | "rt"
| "rp" => Display::Inline,
"tr" => Display::Flex,
_ => Display::Block,
};
if tag == "center" {
// Browser UA sheets keep <center> block-level and give it a special
// inherited text alignment which also centers fixed-width block
// descendants. Keep that provenance separate from ordinary authored
// text-align:center.
style.text_align = Some(taffy::AlignItems::CENTER);
style.legacy_center = true;
} else if tag == "head" || tag == "script" || tag == "style" || tag == "title" || tag == "meta" || tag == "link" || tag == "noscript" || tag == "template"
|| tag == "desc" || tag == "metadata" || tag == "option" || tag == "optgroup"
|| tag == "source" || tag == "track" || tag == "param" || tag == "area" {
// `noscript` content is only for scripting-disabled agents; with JS on
// (as here) the parser keeps it as raw text and the browser hides it,
// so a site's no-JS nav fallback must not paint as literal markup.
// `template` content is inert and never rendered. svg `title`/`desc`/
// `metadata` are AX/tooltip metadata, never rendered in flow (an inline
// <svg> we cannot rasterize would otherwise leak its `<desc>` text).
// `option`/`optgroup` render only inside the native select popup, so a
// closed <select> must not paint every option label stacked.
// `source`/`track`/`param`/`area` are metadata-only children of
// picture/video/object/map; a `<picture><source width= height=>` must
// not lay out as a real box (news CDNs put dimensions on `<source>`,
// which otherwise paints an empty box the size of the image).
style.display = crate::Display::None;
} else if tag == "br" {
// Keep forced breaks as full-width sentinels in the general
// flex/block fallback. The builder replaces the zero height with the
// inherited used line-height; pure and mixed inline runs fold the tag
// into the shaped line stream instead.
style.width = crate::Dimension::Percent(1.0);
style.height = crate::Dimension::Px(0.0);
} else if tag == "pre" {
// HTML's UA sheet preserves source whitespace in preformatted blocks.
style.white_space = Some(crate::WhiteSpace::Pre);
} else if tag == "body" {
style.margin = Edges { top: 8.0, right: 8.0, bottom: 8.0, left: 8.0 };
} else if tag == "h1" {
style.font_size = Some(32.0);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 };
} else if tag == "h2" {
style.font_size = Some(24.0);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 19.0, bottom: 19.0, left: 0.0, right: 0.0 };
} else if tag == "h3" {
style.font_size = Some(18.7);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 18.0, bottom: 18.0, left: 0.0, right: 0.0 };
} else if tag == "h4" || tag == "h5" || tag == "h6" {
style.font_size = Some(16.0);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 };
} else if matches!(tag, "p" | "ul" | "ol" | "menu" | "dir") {
style.margin = Edges { top: 16.0, bottom: 16.0, left: 0.0, right: 0.0 };
if matches!(tag, "ul" | "menu" | "dir") {
style.list_style = Some(crate::ListStyle::Disc);
style.padding.left = 40.0;
} else if tag == "ol" {
style.list_style = Some(crate::ListStyle::Decimal);
style.padding.left = 40.0;
}
} else if tag == "b" || tag == "strong" {
style.font_weight = Some("bold".to_string());
} else if tag == "i" || tag == "em" || tag == "cite" || tag == "var" || tag == "dfn" || tag == "address" {
style.font_style_italic = Some(true);
} else if tag == "a" {
style.color = Some([0, 0, 238, 255]); // blue
style.underline = Some(true); // UA default: links are underlined
} else if tag == "button" {
// HTML buttons are inline-block controls whose anonymous inner
// content is centered by the browser UA sheet. Keep the atomic outer
// participation separate from the internal text alignment: authored
// display/text-align declarations still cascade over both values.
style.display = Display::Inline;
style.is_inline_block = true;
style.text_align = Some(taffy::AlignItems::CENTER);
style.box_sizing = crate::BoxSizing::BorderBox;
// Chromium's native button content box keeps one pixel of block-axis
// and six pixels of inline-axis padding. Framework resets commonly
// remove the native border/background but intentionally retain this
// padding; percentage-height icon children therefore resolve against
// the padded content box (44px border box -> 42px content box).
style.padding = Edges {
top: 1.0,
right: 6.0,
bottom: 1.0,
left: 6.0,
};
} else if tag == "select" {
// A closed native select is an atomic inline control. Its option
// children belong to the popup (and remain display:none in our frame
// tree); the select itself gets intrinsic geometry and paints the
// selected label.
style.display = Display::Inline;
style.is_inline_block = true;
style.font_size = Some(13.333_333);
style.font_family = Some("arial".to_string());
style.line_height = Some(crate::LineHeight::Normal);
style.padding = Edges {
top: 1.0,
right: 20.0,
bottom: 1.0,
left: 2.0,
};
style.border = Edges {
top: 1.0,
right: 1.0,
bottom: 1.0,
left: 1.0,
};
style.border_color = Some([118, 118, 118, 255]);
style.background_color = Some([255, 255, 255, 255]);
} else if tag == "input" {
// Native text controls are atomic inline-level boxes with their own
// platform font and intrinsic border-box dimensions; they do not
// inherit the page's font shorthand by default. The declared CSS box
// remains content-box in standards mode and switches in quirks mode.
// Size-dependent geometry is resolved after cascading, once the input
// type and `size` attribute are available (dom::layout_dom).
style.display = Display::Inline;
style.is_inline_block = true;
style.font_size = Some(13.333_333);
style.font_family = Some("arial".to_string());
style.line_height = Some(crate::LineHeight::Normal);
style.padding = Edges {
top: 1.0,
right: 2.0,
bottom: 1.0,
left: 2.0,
};
style.border = Edges {
top: 2.0,
right: 2.0,
bottom: 2.0,
left: 2.0,
};
style.border_color = Some([118, 118, 118, 255]);
style.background_color = Some([255, 255, 255, 255]);
} else if matches!(tag, "table" | "tbody" | "thead" | "tfoot") {
style.display = Display::Flex;
style.internal_flex_container = true;
style.flex_direction = Some(taffy::FlexDirection::Column);
style.align_items = Some(taffy::AlignItems::STRETCH); // stretch rows to fill table width
// Rows fill the table width and may shrink below their content's
// min-content size (the flexbox automatic-minimum-size gotcha), so a
// width-constrained taxobox contains its content instead of blowing
// out sideways. (Fully matching CSS auto table layout, where a table
// grows to fit unshrinkable content, needs real table layout.)
style.min_width = crate::Dimension::Px(0.0);
if tag == "table" {
// Chromium's HTML UA sheet makes the table grid border-box and
// supplies the traditional two-pixel separate-border spacing.
// Author declarations and the legacy `cellspacing` hint cascade
// over these values.
style.box_sizing = crate::BoxSizing::BorderBox;
style.border_spacing = Some((2.0, 2.0));
style.border_collapse = Some(false);
} else {
style.width = crate::Dimension::Percent(1.0);
// The row-group UA rule is the source of the effective default
// middle alignment; rows and cells inherit it below.
style.vertical_align = Some(crate::VerticalAlign::Middle);
}
} else if tag == "tr" {
style.internal_flex_container = true;
// Rows fill the table width and can shrink below content min-content;
// this is exactly why Wikipedia's own responsive CSS uses
// `tr{min-width:100%}`. `align-items:stretch` alone did not pin them
// once a cell's content (a 250px no-wrap widget) exceeded the box.
style.min_width = crate::Dimension::Px(0.0);
style.width = crate::Dimension::Percent(1.0);
} else if tag == "td" || tag == "th" {
style.display = Display::Flex;
style.internal_flex_container = true;
style.flex_direction = Some(taffy::FlexDirection::Column);
style.align_items = Some(taffy::AlignItems::FLEX_START);
style.padding = Edges {
top: 1.0,
right: 1.0,
bottom: 1.0,
left: 1.0,
};
style.min_width = crate::Dimension::Px(0.0);
if tag == "th" {
style.font_weight = Some("bold".to_string());
}
} else if tag == "img" {
// Images are inline-level replaced elements by default. Model the
// atomic inner box with the same inline-block marker used by native
// controls, so an icon between text fragments participates in their
// line instead of splitting the parent into block runs. Chromium's UA
// sheet does not impose a responsive max-width: author/reset CSS must
// opt into that. A synthetic `max-width:100%` here distorts deliberately
// oversized art whose height and intrinsic ratio establish its width.
style.display = Display::Inline;
style.is_inline_block = true;
}
style
}
pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
let (normal, important) = partition_declarations(css);
let inherited_scheme = style.color_scheme_dark;
apply_color_scheme_declarations_from(style, &normal, inherited_scheme);
apply_color_scheme_declarations_from(style, &important, inherited_scheme);
apply_declarations_with_locked_color_scheme(style, &normal);
apply_declarations_with_locked_color_scheme(style, &important);
}
/// Split a declaration block into normal and `!important` declarations while
/// preserving source order inside each priority. The returned declarations
/// have the priority marker removed, ready for the computed-style pass.
pub(crate) fn partition_declarations(css: &str) -> (String, String) {
let mut normal = String::new();
let mut important = String::new();
for raw in split_declarations(css) {
let decl = raw.trim();
if decl.is_empty() {
continue;
}
let Some((name, value)) = decl.split_once(':') else { continue };
let mut value = value.trim();
let mut is_important = false;
if let Some(bang) = value.rfind('!') {
if value[bang + 1..].trim().eq_ignore_ascii_case("important") {
value = value[..bang].trim_end();
is_important = true;
}
}
let out = if is_important { &mut important } else { &mut normal };
out.push_str(name.trim());
out.push(':');
out.push_str(value);
out.push(';');
}
(normal, important)
}
/// Apply only `color-scheme` from a declaration list. The stylesheet cascade
/// runs this over every matching block before resolving color-valued
/// declarations, because `light-dark()` selects from the element's final used
/// scheme regardless of whether `color-scheme` appears before or after the
/// color in source order.
pub(crate) fn apply_color_scheme_declarations_from(
style: &mut LayoutStyle,
css: &str,
inherited_scheme: bool,
) {
for raw in split_declarations(css) {
let Some((name, value)) = raw.trim().split_once(':') else { continue };
if name.trim().eq_ignore_ascii_case("color-scheme") {
apply_color_scheme(style, value.trim(), inherited_scheme);
}
}
}
pub(crate) fn apply_declarations_with_locked_color_scheme(
style: &mut LayoutStyle,
css: &str,
) {
for raw in split_declarations(css) {
let decl = raw.trim();
if decl.is_empty() {
continue;
}
let Some((name, value)) = decl.split_once(':') else {
continue;
};
let name = name.trim().to_ascii_lowercase();
if name != "color-scheme" {
apply_value(style, &name, value.trim());
}
}
}
pub(crate) fn apply_animation_declarations(style: &mut LayoutStyle, css: &str) {
for raw in split_declarations(css) {
let Some((name, value)) = raw.trim().split_once(':') else { continue };
let name = name.trim().to_ascii_lowercase();
if name == "animation" || name.starts_with("animation-") {
apply_value(style, &name, value.trim());
}
}
}
fn apply_color_scheme(
style: &mut LayoutStyle,
value: &str,
inherited_scheme: bool,
) {
let tokens: Vec<String> = value
.split_whitespace()
.map(|token| token.to_ascii_lowercase())
.collect();
if tokens.iter().any(|token| token == "inherit" || token == "unset") {
style.color_scheme_dark = inherited_scheme;
return;
}
if tokens.iter().any(|token| {
matches!(token.as_str(), "initial" | "revert" | "revert-layer")
}) {
style.color_scheme_dark = false;
return;
}
// The current browser/user preference is light. A scheme list that admits
// light therefore uses light; a dark-only list uses dark. `normal`,
// initial/revert, and malformed values retain the default/inherited light
// behavior used by this compact computed-style model.
if tokens.iter().any(|token| token == "light")
|| tokens.iter().any(|token| token == "normal")
{
style.color_scheme_dark = false;
} else if tokens.iter().any(|token| token == "dark") {
style.color_scheme_dark = true;
}
}
/// Split a declaration list on top-level semicolons, respecting `url(...)`
/// and quoted strings. A data: URI (`url(data:image/svg+xml;utf8,...)`, an
/// extremely common way to inline small icon SVGs) or a quoted string
/// (`content: "a; b"`) routinely contains a literal semicolon that is not a
/// declaration separator; splitting on every `;` blindly corrupts the
/// declaration into two malformed halves and silently drops it.
pub(crate) fn split_declarations(css: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut in_quote: Option<char> = None;
let mut start = 0;
for (i, c) in css.char_indices() {
if let Some(q) = in_quote {
if c == q {
in_quote = None;
}
continue;
}
match c {
'\'' | '"' => in_quote = Some(c),
// Track `{...}` too: once @layer bodies are admitted, CSS-nested
// rules (`&:hover{a:b}`, nested @media) appear inside declaration
// lists. Keeping a nested block as one chunk makes it a single
// unparseable declaration that is dropped, rather than leaking its
// inner declarations into the parent rule at the first `;`.
'(' | '{' => depth += 1,
')' | '}' => depth = (depth - 1).max(0),
';' if depth == 0 => {
parts.push(&css[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&css[start..]);
parts
}
fn parse_container_type(value: &str) -> Option<crate::ContainerType> {
match value.trim().to_ascii_lowercase().as_str() {
"normal" => Some(crate::ContainerType::Normal),
"inline-size" => Some(crate::ContainerType::InlineSize),
"size" => Some(crate::ContainerType::Size),
"initial" | "unset" | "revert" | "revert-layer" => {
Some(crate::ContainerType::Normal)
}
_ => None,
}
}
fn parse_container_names(value: &str) -> Option<Vec<String>> {
let value = value.trim();
if value.is_empty() {
return None;
}
if matches!(
value.to_ascii_lowercase().as_str(),
"none" | "initial" | "unset" | "revert" | "revert-layer"
) {
return Some(Vec::new());
}
let mut names = Vec::new();
let mut input = cssparser::ParserInput::new(value);
let mut parser = cssparser::Parser::new(&mut input);
while !parser.is_exhausted() {
let ident = parser.expect_ident_cloned().ok()?;
let lower = ident.to_ascii_lowercase();
if matches!(lower.as_str(), "none" | "not" | "and" | "or") {
return None;
}
names.push(ident.to_string());
}
(!names.is_empty()).then_some(names)
}
fn parse_container_shorthand(value: &str) -> Option<(Vec<String>, crate::ContainerType)> {
let value = value.trim();
if matches!(value.to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer")
{
return Some((Vec::new(), crate::ContainerType::Normal));
}
let parts = split_top_level(value, '/');
match parts.as_slice() {
[names] => Some((parse_container_names(names)?, crate::ContainerType::Normal)),
[names, kind] => Some((parse_container_names(names)?, parse_container_type(kind)?)),
_ => None,
}
}
#[derive(Clone, Copy)]
struct ParsedOverflowAxis {
specified: u8,
inherit: bool,
}
fn parse_overflow_axis(value: &str) -> Option<ParsedOverflowAxis> {
let lower = value.trim().to_ascii_lowercase();
let (specified, inherit) = match lower.as_str() {
"visible" => (0, false),
"clip" => (1, false),
"hidden" | "scroll" | "auto" | "overlay" => (2, false),
"inherit" => (0, true),
"initial" | "unset" | "revert" | "revert-layer" => (0, false),
_ => return None,
};
Some(ParsedOverflowAxis { specified, inherit })
}
fn parse_overflow_declaration(
name: &str,
value: &str,
) -> Option<(Option<ParsedOverflowAxis>, Option<ParsedOverflowAxis>)> {
match name {
"overflow-x" => Some((Some(parse_overflow_axis(value)?), None)),
"overflow-y" => Some((None, Some(parse_overflow_axis(value)?))),
"overflow" => {
let values = split_ws_paren(value);
if values.is_empty() || values.len() > 2 {
return None;
}
// CSS-wide keywords apply to the whole shorthand and cannot be
// mixed with another component.
let first = parse_overflow_axis(values[0])?;
if first.inherit
|| matches!(
values[0].to_ascii_lowercase().as_str(),
"initial" | "unset" | "revert" | "revert-layer"
)
{
return (values.len() == 1).then_some((Some(first), Some(first)));
}
let second = if let Some(value) = values.get(1) {
let parsed = parse_overflow_axis(value)?;
if parsed.inherit
|| matches!(
value.to_ascii_lowercase().as_str(),
"initial" | "unset" | "revert" | "revert-layer"
)
{
return None;
}
parsed
} else {
first
};
Some((Some(first), Some(second)))
}
_ => None,
}
}
pub(crate) fn recompute_overflow(style: &mut LayoutStyle) {
// CSS Overflow computed-value coupling: if exactly one axis is scrollable,
// `visible` on the other computes to `auto` and `clip` computes to
// `hidden`. A clip/visible pair remains genuinely axis-specific.
let mut computed_x = style.overflow_specified_x;
let mut computed_y = style.overflow_specified_y;
if (computed_x == 2) != (computed_y == 2) {
if computed_x == 2 {
computed_y = 2;
} else {
computed_x = 2;
}
}
style.overflow_clip_x = computed_x != 0;
style.overflow_clip_y = computed_y != 0;
style.overflow_scroll_x = computed_x == 2;
style.overflow_scroll_y = computed_y == 2;
style.overflow_hidden = style.overflow_clip_x || style.overflow_clip_y;
style.overflow_scroll_container = style.overflow_scroll_x || style.overflow_scroll_y;
}
fn parse_font_variation_settings(value: &str) -> Option<Vec<crate::FontVariationSetting>> {
let mut input = cssparser::ParserInput::new(value.trim());
let mut parser = cssparser::Parser::new(&mut input);
if parser.is_exhausted() {
return None;
}
// A map both implements CSS's "last duplicate wins" rule and gives the
// inline engine a stable tag order independent of author ordering.
let mut settings = BTreeMap::new();
loop {
let tag = parser.expect_string_cloned().ok()?;
let bytes = tag.as_bytes();
if bytes.len() != 4 || !bytes.iter().all(|byte| (0x20..=0x7e).contains(byte)) {
return None;
}
let value = parse_font_variation_number(&mut parser)?;
if !value.is_finite() {
return None;
}
let tag = [bytes[0], bytes[1], bytes[2], bytes[3]];
// Keep numerically equivalent signed zeroes canonical for stable cache
// keys later in the shaping and rasterization pipeline.
settings.insert(tag, if value == 0.0 { 0.0 } else { value });
if parser.is_exhausted() {
break;
}
parser.expect_comma().ok()?;
if parser.is_exhausted() {
return None;
}
}
Some(
settings
.into_iter()
.map(|(tag, value)| crate::FontVariationSetting { tag, value })
.collect(),
)
}
fn parse_font_variation_number(parser: &mut cssparser::Parser<'_, '_>) -> Option<f32> {
if let Ok(value) = parser.try_parse(|input| input.expect_number()) {
return value.is_finite().then_some(value);
}
let start = parser.position();
let token = parser.next().ok()?.clone();
let cssparser::Token::Function(name) = token else {
return None;
};
if !matches!(
name.to_ascii_lowercase().as_str(),
"calc" | "min" | "max" | "clamp" | "round"
) {
return None;
}
parser
.parse_nested_block(|input| unitless_math_tokens(input, 0))
.ok()?;
let expression = parser.slice_from(start);
resolve_contextual_length(expression, 0.0, 0.0, 0.0, 0.0, 0.0)
.filter(|value| value.is_finite())
}
fn unitless_math_tokens<'i, 't>(
parser: &mut cssparser::Parser<'i, 't>,
depth: u8,
) -> Result<(), cssparser::ParseError<'i, ()>> {
if depth >= 64 {
return Err(parser.new_custom_error(()));
}
while !parser.is_exhausted() {
let token = parser
.next_including_whitespace_and_comments()?
.clone();
match token {
cssparser::Token::Number { .. }
| cssparser::Token::WhiteSpace(_)
| cssparser::Token::Comment(_)
| cssparser::Token::Comma
| cssparser::Token::Delim('+')
| cssparser::Token::Delim('-')
| cssparser::Token::Delim('*')
| cssparser::Token::Delim('/') => {}
cssparser::Token::Function(name)
if matches!(
name.to_ascii_lowercase().as_str(),
"calc" | "min" | "max" | "clamp" | "round"
) =>
{
parser.parse_nested_block(|input| unitless_math_tokens(input, depth + 1))?;
}
cssparser::Token::ParenthesisBlock => {
parser.parse_nested_block(|input| unitless_math_tokens(input, depth + 1))?;
}
_ => return Err(parser.new_custom_error(())),
}
}
Ok(())
}
fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
match name {
"display" => {
let value = value.trim().to_ascii_lowercase();
if matches!(
value.as_str(),
"none"
| "flex"
| "inline-flex"
| "inline"
| "inline-block"
| "grid"
| "inline-grid"
| "block"
| "flow-root"
| "table"
| "inline-table"
| "contents"
| "inherit"
| "initial"
| "unset"
) {
// Every valid authored display value replaces the complete
// outer/inner display pair. In particular, it must not retain
// the UA table/control approximation merely because its
// internal enum happens to equal the newly specified value.
style.internal_flex_container = false;
style.is_table_box = false;
style.is_inline_block = false;
style.flow_root = false;
style.display_contents = false;
style.display_inherit = false;
}
match value.as_str() {
"none" => style.display = crate::Display::None,
"flex" => style.display = crate::Display::Flex,
"inline-flex" => {
style.display = crate::Display::Flex;
style.is_inline_block = true;
}
"inline" => style.display = crate::Display::Inline,
"inline-block" => {
style.display = crate::Display::Inline;
style.is_inline_block = true;
}
"grid" => style.display = crate::Display::Grid,
"inline-grid" => {
style.display = crate::Display::Grid;
style.is_inline_block = true;
}
"block" => style.display = crate::Display::Block,
"flow-root" => {
style.display = crate::Display::Block;
style.flow_root = true;
}
// Taffy has no table formatting mode. Preserve the important
// outer-display and BFC semantics instead of ignoring these
// values and retaining a stale earlier `display` winner.
// Native HTML tables still take the dedicated table builder.
"table" => {
style.display = crate::Display::Block;
style.flow_root = true;
style.is_table_box = true;
}
"inline-table" => {
style.display = crate::Display::Inline;
style.is_inline_block = true;
style.flow_root = true;
style.is_table_box = true;
}
"contents" => {
// `display:contents` can override an earlier `display:none`
// in the cascade (responsive desktop/mobile wrappers do
// this constantly). It suppresses only this element's box;
// its children remain generated and are flattened into the
// parent formatting context.
style.display = crate::Display::Block;
style.display_contents = true;
}
"inherit" => {
// Keep a valid initial-display placeholder until the
// parent computed style is available. The top-down pass
// copies the parent's outer and inner display provenance,
// but never its engine-only native-layout flags.
style.display = crate::Display::Inline;
style.display_inherit = true;
}
"initial" | "unset" => {
// `display` is not inherited and its CSS initial value is
// `inline`, independent of the element's HTML UA display.
style.display = crate::Display::Inline;
}
_ => {}
}
}
"container-type" => {
if value.eq_ignore_ascii_case("inherit") {
style.container_type = crate::ContainerType::Normal;
style.container_type_inherit = true;
} else if let Some(kind) = parse_container_type(value) {
style.container_type = kind;
style.container_type_inherit = false;
}
}
"container-name" => {
if value.eq_ignore_ascii_case("inherit") {
style.container_names.clear();
style.container_names_inherit = true;
} else if let Some(names) = parse_container_names(value) {
style.container_names = names;
style.container_names_inherit = false;
}
}
"container" => {
if value.eq_ignore_ascii_case("inherit") {
style.container_names.clear();
style.container_type = crate::ContainerType::Normal;
style.container_names_inherit = true;
style.container_type_inherit = true;
} else if let Some((names, kind)) = parse_container_shorthand(value) {
style.container_names = names;
style.container_type = kind;
style.container_names_inherit = false;
style.container_type_inherit = false;
}
}
"width" => {
style.width = dimension_value(value);
style.width_fit_content = value.trim().eq_ignore_ascii_case("fit-content");
style.size_expressions[0] = deferred_length_expression(value);
style.width_set = true;
}
"height" => {
style.height = dimension_value(value);
style.size_expressions[1] = deferred_length_expression(value);
style.height_set = true;
}
"box-sizing" => {
let value = value.trim();
style.box_sizing = if value.eq_ignore_ascii_case("border-box") {
crate::BoxSizing::BorderBox
} else if value.eq_ignore_ascii_case("content-box") {
crate::BoxSizing::ContentBox
} else if value.eq_ignore_ascii_case("initial")
|| value.eq_ignore_ascii_case("unset")
|| value.eq_ignore_ascii_case("revert")
|| value.eq_ignore_ascii_case("revert-layer")
{
// `box-sizing` is not inherited, so both its initial value and
// `unset` compute to content-box. The compact cascade cannot
// recover a declaration from a lower origin/layer after it has
// applied an author winner; consistently with the other
// non-inherited properties here, approximate both revert
// forms with the initial value rather than retaining an
// earlier declaration from the same author origin.
crate::BoxSizing::ContentBox
} else if value.eq_ignore_ascii_case("inherit") {
// Preserve the specified inherit marker until the DOM's
// top-down computed-style pass can resolve it from the parent.
crate::BoxSizing::Inherit
} else {
style.box_sizing
};
}
"min-width" => {
style.min_width = dimension_value(value);
style.size_expressions[2] = deferred_length_expression(value);
}
"min-height" => {
style.min_height = dimension_value(value);
style.size_expressions[3] = deferred_length_expression(value);
}
"max-width" => {
style.max_width = dimension_value(value);
style.size_expressions[4] = deferred_length_expression(value);
}
"max-height" => {
style.max_height = dimension_value(value);
style.size_expressions[5] = deferred_length_expression(value);
}
"aspect-ratio" => {
style.aspect_ratio = parse_aspect_ratio(value);
style.aspect_ratio_is_mapped = false;
}
"margin" => apply_margin_shorthand(style, value),
"margin-top" => set_margin_side(style, 0, value),
"margin-right" => set_margin_side(style, 1, value),
"margin-bottom" => set_margin_side(style, 2, value),
"margin-left" => set_margin_side(style, 3, value),
// Logical margins (LTR: inline = left/right, block = top/bottom).
"margin-inline" => { let (s, e) = two(value); set_margin_side(style, 3, s); set_margin_side(style, 1, e); }
"margin-inline-start" => set_margin_side(style, 3, value),
"margin-inline-end" => set_margin_side(style, 1, value),
"margin-block" => { let (s, e) = two(value); set_margin_side(style, 0, s); set_margin_side(style, 2, e); }
"margin-block-start" => set_margin_side(style, 0, value),
"margin-block-end" => set_margin_side(style, 2, value),
"padding" => apply_padding_shorthand(style, value),
"padding-top" => set_padding_side(style, 0, value),
"padding-right" => set_padding_side(style, 1, value),
"padding-bottom" => set_padding_side(style, 2, value),
"padding-left" => set_padding_side(style, 3, value),
"padding-inline" => { let (s, e) = two(value); set_padding_side(style, 3, s); set_padding_side(style, 1, e); }
"padding-inline-start" => set_padding_side(style, 3, value),
"padding-inline-end" => set_padding_side(style, 1, value),
"padding-block" => { let (s, e) = two(value); set_padding_side(style, 0, s); set_padding_side(style, 2, e); }
"padding-block-start" => set_padding_side(style, 0, value),
"padding-block-end" => set_padding_side(style, 2, value),
"border-radius" => {
// Uniform radius from the first value (ignore per-corner / the
// `/` vertical-radius form; the common case is one
// <length-percentage>). A percentage must remain unresolved until
// paint knows both axes of the final border box.
let token = value.split('/').next().and_then(|part| part.split_whitespace().next());
if matches!(
value.trim().to_ascii_lowercase().as_str(),
"initial" | "unset" | "revert" | "revert-layer"
) {
style.border_radius = crate::BorderRadius::default();
} else if let Some(percentage) = token
.and_then(|token| token.strip_suffix('%'))
.and_then(|number| number.parse::<f32>().ok())
.filter(|number| number.is_finite() && *number >= 0.0)
{
style.border_radius = crate::BorderRadius::percentage(percentage / 100.0);
} else if let Some(length) = token
.and_then(px)
.filter(|length| length.is_finite() && *length >= 0.0)
{
style.border_radius = crate::BorderRadius::pixels(length);
}
}
"clip-path" | "-webkit-clip-path" => {
let lower = value.trim().to_ascii_lowercase();
if matches!(
lower.as_str(),
"none" | "initial" | "unset" | "revert" | "revert-layer"
) {
style.clip_path = None;
} else if let Some(polygon) = parse_clip_path_polygon(value) {
style.clip_path = Some(polygon);
}
}
"border" => {
if value.split_whitespace().any(|token| {
token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden")
}) {
style.border = Edges::default();
style.border_color = None;
return;
}
for p in split_ws_paren(value) {
if let Some(c) =
parse_color_for_scheme(p, style.color_scheme_dark)
{
style.border_color = Some(c);
} else if p.ends_with("px") || p.chars().all(|c| c.is_ascii_digit()) {
if let Some(e) = edges(p) { style.border = e; }
}
}
}
"border-width" => { if let Some(e) = edges(value) { style.border = e; } }
"border-top-width" | "border-top" => {
set_edge(&mut style.border, Side::Top, border_side_width(value))
}
"border-right-width" | "border-right" => {
set_edge(&mut style.border, Side::Right, border_side_width(value))
}
"border-bottom-width" | "border-bottom" => {
set_edge(&mut style.border, Side::Bottom, border_side_width(value))
}
"border-left-width" | "border-left" => {
set_edge(&mut style.border, Side::Left, border_side_width(value))
}
"background-color" => {
style.background_color =
parse_color_for_scheme(value, style.color_scheme_dark)
}
"background" => {
// A shorthand resets every omitted background longhand to its
// initial value before applying the layers it does name.
// `background:0` is a valid position-only shorthand commonly used
// to clear a component background; merely assigning the fields we
// can parse leaves an earlier color/image painting underneath it.
// An empty value is invalid (for example an unresolved `var()`),
// so leave the prior cascade winner untouched in that case.
if !value.trim().is_empty() {
style.background_color = None;
set_background_gradients(style, value);
if style.background_gradient.is_none()
&& style.background_radial_gradient.is_none()
&& style.background_conic_gradient.is_none()
{
style.background_color =
parse_color_for_scheme(value, style.color_scheme_dark);
}
style.background_image = parse_url(value);
style.background_size = None;
style.background_size_expression = background_size_expression(value);
style.background_size_fit = parse_background_size_fit(value);
style.background_position = crate::BackgroundPosition::default();
style.background_repeat = parse_image_repeat(value);
style.background_clip_text = false;
}
}
"background-image" => {
set_background_gradients(style, value);
style.background_image = parse_url(value);
}
"background-size" => {
style.background_size = parse_background_size(value);
style.background_size_expression =
(!value.trim().is_empty()).then(|| value.trim().to_string());
style.background_size_fit = parse_background_size_fit(value);
}
"background-position" => style.background_position = parse_background_position(value),
"background-repeat" => {
style.background_repeat = parse_image_repeat(value);
}
// On replaced elements, an image-valued `content` replaces the
// ordinary source and participates in intrinsic sizing. String-valued
// generated content is handled separately for pseudo-elements.
"content" => style.content_image = parse_url(value),
"mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(value),
"mask-size" | "-webkit-mask-size" => {
style.mask_size = parse_background_size(value)
}
"mask-repeat" | "-webkit-mask-repeat" => {
style.mask_repeat = parse_image_repeat(value);
}
"background-clip" | "-webkit-background-clip" => {
// `text` clips the background to the element's glyphs (the gradient/
// solid-color text technique). Any box value (border-box/padding-box/
// content-box) is an ordinary background, so clear the flag.
style.background_clip_text = value.trim().eq_ignore_ascii_case("text");
}
// Blink/WebKit gradient text commonly makes the glyph fill
// transparent through this inherited property while clipping a
// background gradient to the text. We model one effective text color,
// so the vendor fill color is the paint-time color winner.
"color" | "-webkit-text-fill-color" => {
style.color = parse_color_for_scheme(value, style.color_scheme_dark)
}
// Inline SVG is rasterized as a standalone SVG document. Preserve
// author-CSS presentation values verbatim so paint can carry the
// computed cascade into that document (including currentColor,
// `none`, and paint-server URLs).
"fill" => {
style.svg_fill = Some(resolve_svg_presentation_color(
value,
style.color_scheme_dark,
))
}
"stroke" => {
style.svg_stroke = Some(resolve_svg_presentation_color(
value,
style.color_scheme_dark,
))
}
"stroke-width" => style.svg_stroke_width = Some(value.trim().to_string()),
"border-color" => {
style.border_color =
parse_color_for_scheme(value, style.color_scheme_dark)
}
"font-size" => {
// Absolute lengths resolve now; font/viewport-relative ones defer
// to the inheritance pass (they need parent/root font-size).
apply_font_size(style, value);
}
"letter-spacing" => apply_letter_spacing(style, value),
"font" => apply_font_shorthand(style, value),
"font-weight" => {
let lower = value.trim().to_ascii_lowercase();
match lower.as_str() {
// font-weight is inherited. Preserve the inherited marker
// until the DOM's top-down computed-style pass can see the
// parent's numeric weight. `unset` has the same behavior for
// an inherited property.
"inherit" | "unset" => {
style.font_weight = Some("inherit".to_string());
}
// The initial font weight is normal, independent of both the
// parent and a heading's bold UA rule.
"initial" => {
style.font_weight = Some("400".to_string());
}
// The compact cascade does not retain origin/layer history,
// so leave its current state unchanged instead of pretending
// the revert forms mean inherit or initial.
"revert" | "revert-layer" => {}
_ => {
if let Some(weight) = specified_font_weight(&lower) {
style.font_weight = Some(weight);
}
}
}
}
"font-family" => {
let v = value.trim().to_ascii_lowercase();
if !v.is_empty() && v != "inherit" {
style.font_family = Some(v);
}
}
"font-optical-sizing" => {
let parsed = match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "unset" | "revert" | "revert-layer" => Some(None),
"auto" | "initial" => Some(Some(crate::FontOpticalSizing::Auto)),
"none" => Some(Some(crate::FontOpticalSizing::None)),
_ => None,
};
if let Some(value) = parsed {
style.font_optical_sizing = value;
}
}
"font-variation-settings" => {
let lower = value.trim().to_ascii_lowercase();
if matches!(
lower.as_str(),
"inherit" | "unset" | "revert" | "revert-layer"
) {
style.font_variation_settings = None;
} else if matches!(lower.as_str(), "normal" | "initial") {
style.font_variation_settings = Some(Vec::new());
} else if let Some(settings) = parse_font_variation_settings(value) {
style.font_variation_settings = Some(settings);
}
}
// Text alignment is inherited and applies to inline line boxes. It is
// deliberately separate from flex/grid `align-items`, which positions
// child boxes rather than text inside them.
"text-align" => match value {
"right" | "end" => {
style.text_align = Some(taffy::AlignItems::FLEX_END);
style.legacy_center = false;
}
"center" => {
style.text_align = Some(taffy::AlignItems::CENTER);
style.legacy_center = false;
}
"left" | "start" | "justify" => {
style.text_align = Some(taffy::AlignItems::FLEX_START);
style.legacy_center = false;
}
_ => {}
},
"text-indent" => apply_text_indent(style, value),
"align-items" => {
if let Some(Some(value)) = self_alignment_value(value) {
style.align_items = Some(value);
}
},
"justify-items" => {
if let Some(Some(value)) = self_alignment_value(value) {
style.justify_items = Some(value);
}
},
"place-items" => {
if let Some((Some(align), Some(justify))) = self_alignment_pair(value) {
style.align_items = Some(align);
style.justify_items = Some(justify);
}
},
"align-self" => {
if let Some(value) = self_alignment_value(value) {
style.align_self = value;
}
},
"justify-self" => {
if let Some(value) = self_alignment_value(value) {
style.justify_self = value;
}
},
"place-self" => {
if let Some((align, justify)) = self_alignment_pair(value) {
style.align_self = align;
style.justify_self = justify;
}
},
"align-content" => {
if let Some(value) = content_alignment_value(value) {
style.align_content = Some(value);
}
},
"justify-content" => {
let value = match value.trim().to_ascii_lowercase().as_str() {
"left" => Some(taffy::JustifyContent::START),
"right" => Some(taffy::JustifyContent::END),
_ => content_alignment_value(value),
};
if let Some(value) = value {
style.justify_content = Some(value);
}
},
"place-content" => {
if let Some((align, justify)) = content_alignment_pair(value) {
style.align_content = Some(align);
style.justify_content = Some(justify);
}
}
"flex-direction" => match value {
"row" => style.flex_direction = Some(taffy::FlexDirection::Row),
"row-reverse" => style.flex_direction = Some(taffy::FlexDirection::RowReverse),
"column" => style.flex_direction = Some(taffy::FlexDirection::Column),
"column-reverse" => style.flex_direction = Some(taffy::FlexDirection::ColumnReverse),
_ => {}
},
"flex-wrap" => {
match value {
"wrap" => style.flex_wrap = Some(taffy::FlexWrap::Wrap),
"nowrap" => style.flex_wrap = Some(taffy::FlexWrap::NoWrap),
"wrap-reverse" => style.flex_wrap = Some(taffy::FlexWrap::WrapReverse),
_ => {}
}
},
"flex-grow" => { if let Some(v) = token(value).and_then(|t| t.parse::<f32>().ok()) { style.flex_grow = Some(v); } }
"flex-shrink" => { if let Some(v) = token(value).and_then(|t| t.parse::<f32>().ok()) { style.flex_shrink = Some(v); } }
"order" => {
if let Ok(order) = value.trim().parse::<i32>() {
style.order = order;
}
}
"flex-basis" => { style.flex_basis = dimension_value(value.trim()); }
"flex" => parse_flex_shorthand(style, value),
"position" => match value {
"absolute" => {
style.position = Some(taffy::Position::Absolute);
style.position_fixed = false;
style.position_sticky = false;
}
"fixed" => {
style.position = Some(taffy::Position::Absolute);
style.position_fixed = true;
style.position_sticky = false;
}
"relative" => {
style.position = Some(taffy::Position::Relative);
style.position_fixed = false;
style.position_sticky = false;
}
"sticky" => {
style.position = Some(taffy::Position::Relative);
style.position_fixed = false;
style.position_sticky = true;
}
"static" => {
style.position = None;
style.position_fixed = false;
style.position_sticky = false;
}
_ => {}
},
"float" => match value {
"left" => style.float = Some(crate::Float::Left),
"right" => style.float = Some(crate::Float::Right),
"none" => style.float = None,
_ => {}
},
"counter-reset" => {
if let Some(counters) = parse_counter_directives(value, 0) {
style.counter_reset = counters;
}
}
"counter-increment" => {
if let Some(counters) = parse_counter_directives(value, 1) {
style.counter_increment = counters;
}
}
"counter-set" => {
if let Some(counters) = parse_counter_directives(value, 0) {
style.counter_set = counters;
}
}
"object-fit" => match value.trim().to_ascii_lowercase().as_str() {
"fill" => style.object_fit = crate::ObjectFit::Fill,
"contain" => style.object_fit = crate::ObjectFit::Contain,
"cover" => style.object_fit = crate::ObjectFit::Cover,
"scale-down" => style.object_fit = crate::ObjectFit::ScaleDown,
"none" => style.object_fit = crate::ObjectFit::None,
_ => {}
},
"top" => set_inset_side(style, 0, value),
"right" => set_inset_side(style, 1, value),
"bottom" => set_inset_side(style, 2, value),
"left" => set_inset_side(style, 3, value),
// Logical insets in the engine's current horizontal LTR writing mode.
// Utility frameworks prefer these over left/right (for example,
// Tailwind's `inset-x-0` emits `inset-inline:0`), so dropping them
// turns stretched fixed overlays into shrink-to-fit boxes.
"inset-inline" => {
let (start, end) = two(value);
set_inset_side(style, 3, start);
set_inset_side(style, 1, end);
}
"inset-inline-start" => set_inset_side(style, 3, value),
"inset-inline-end" => set_inset_side(style, 1, value),
"inset-block" => {
let (start, end) = two(value);
set_inset_side(style, 0, start);
set_inset_side(style, 2, end);
}
"inset-block-start" => set_inset_side(style, 0, value),
"inset-block-end" => set_inset_side(style, 2, value),
"inset" => {
// 1-4 values, CSS shorthand order: all / v h / t h b / t r b l.
let parts = split_ws_paren(value);
let (t, r, b, l) = match parts.as_slice() {
[a] => (*a, *a, *a, *a),
[v, h] => (*v, *h, *v, *h),
[t, h, b] => (*t, *h, *b, *h),
[t, r, b, l, ..] => (*t, *r, *b, *l),
[] => return,
};
set_inset_side(style, 0, t);
set_inset_side(style, 1, r);
set_inset_side(style, 2, b);
set_inset_side(style, 3, l);
}
"overflow" | "overflow-x" | "overflow-y" => {
let Some((x, y)) = parse_overflow_declaration(name, value) else {
return;
};
if let Some(x) = x {
style.overflow_specified_x = x.specified;
style.overflow_inherit_x = x.inherit;
}
if let Some(y) = y {
style.overflow_specified_y = y.specified;
style.overflow_inherit_y = y.inherit;
}
style.overflow_axes_set = true;
recompute_overflow(style);
}
"scrollbar-gutter" => {
let lower = value.to_ascii_lowercase();
style.scrollbar_gutters = if lower
.split_ascii_whitespace()
.any(|token| token == "stable")
{
if lower
.split_ascii_whitespace()
.any(|token| token == "both-edges")
{
2
} else {
1
}
} else {
0
};
}
"visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")),
"opacity" => style.opacity = value.trim().parse::<f32>().ok(),
"animation" => apply_animation_shorthand(style, value),
"animation-name" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
style.animation_name = if first.is_empty()
|| matches!(
first.to_ascii_lowercase().as_str(),
"none" | "initial" | "inherit" | "unset" | "revert" | "revert-layer"
)
{
None
} else {
Some(first.to_string())
};
}
"animation-duration" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.duration_ms = 0.0;
} else if let Some(milliseconds) = parse_animation_time_ms(first).filter(|time| *time >= 0.0) {
style.animation_timing.duration_ms = milliseconds;
}
}
"animation-delay" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.delay_ms = 0.0;
} else if let Some(milliseconds) = parse_animation_time_ms(first) {
style.animation_timing.delay_ms = milliseconds;
}
}
"animation-fill-mode" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.fill_mode = crate::AnimationFillMode::None;
} else if let Some(fill) = parse_animation_fill_mode(first) {
style.animation_timing.fill_mode = fill;
}
}
"animation-iteration-count" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.iteration_count = 1.0;
} else if let Some(iterations) = parse_animation_iteration_count(first) {
style.animation_timing.iteration_count = iterations;
}
}
"animation-direction" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.direction = crate::AnimationDirection::Normal;
} else if let Some(direction) = parse_animation_direction(first) {
style.animation_timing.direction = direction;
}
}
"animation-play-state" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.play_state = crate::AnimationPlayState::Running;
} else if let Some(play_state) = parse_animation_play_state(first) {
style.animation_timing.play_state = play_state;
}
}
"z-index" => {
style.z_index = match value.trim() {
"auto" | "inherit" | "initial" => None,
v => v.parse::<i32>().ok(),
};
}
"clear" => {
style.clear = match value.trim().to_ascii_lowercase().as_str() {
"left" | "inline-start" => Some(crate::Clear::Left),
"right" | "inline-end" => Some(crate::Clear::Right),
"both" => Some(crate::Clear::Both),
_ => None,
};
}
"vertical-align" => {
style.vertical_align = match value.trim().to_ascii_lowercase().as_str() {
"top" | "baseline" | "text-top" => Some(crate::VerticalAlign::Top),
"middle" => Some(crate::VerticalAlign::Middle),
"bottom" | "text-bottom" => Some(crate::VerticalAlign::Bottom),
// sub/super/lengths are text-level; leave the cell default.
_ => style.vertical_align,
};
}
"list-style-type" => {
style.list_style = Some(list_style_keyword(value.trim()).unwrap_or(crate::ListStyle::Disc));
}
"list-style" => {
// Shorthand: type | position | image in any order. We only track
// the type keyword (and `none`, which suppresses the marker, the
// common way nav `<ul>`s drop their bullets).
for tok in value.split_whitespace() {
if let Some(ls) = list_style_keyword(tok) {
style.list_style = Some(ls);
}
}
}
"line-height" => {
let v = value.trim();
if v.contains('(') {
style.line_height = None;
style.line_height_expression = Some(v.to_string());
return;
}
style.line_height_expression = None;
style.line_height = if v.eq_ignore_ascii_case("normal") {
Some(crate::LineHeight::Normal)
} else if let Some(pct) = v.strip_suffix('%') {
pct.trim().parse::<f32>().ok().map(|number| {
crate::LineHeight::Relative(crate::Dimension::Percent(number / 100.0))
})
} else if v.ends_with("px") || v.ends_with("pt") {
px_value(v).map(crate::LineHeight::Px)
} else if ["rem", "em", "ex", "vw", "vh", "vmin", "vmax"]
.iter()
.any(|unit| v.ends_with(unit))
{
Some(crate::LineHeight::Relative(dimension_value(v)))
} else {
// Unitless number: a multiple of font-size (the common case).
v.parse::<f32>().ok().map(crate::LineHeight::Ratio)
};
}
"white-space" => {
style.white_space = match value.trim().to_ascii_lowercase().as_str() {
"normal" | "initial" | "revert" | "revert-layer" => {
Some(crate::WhiteSpace::Normal)
}
"nowrap" => Some(crate::WhiteSpace::NoWrap),
"pre" => Some(crate::WhiteSpace::Pre),
"pre-wrap" => Some(crate::WhiteSpace::PreWrap),
"pre-line" => Some(crate::WhiteSpace::PreLine),
"break-spaces" => Some(crate::WhiteSpace::BreakSpaces),
// `white-space` inherits, so unset behaves as inherit.
"inherit" | "unset" => None,
_ => style.white_space,
};
}
"text-wrap" => {
style.text_wrap_style = match value.trim().to_ascii_lowercase().as_str() {
"auto" | "wrap" | "initial" | "revert" | "revert-layer" => {
Some(crate::TextWrapStyle::Auto)
}
"balance" | "wrap balance" | "balance wrap" => Some(crate::TextWrapStyle::Balance),
// `text-wrap` inherits, so unset behaves as inherit.
"inherit" | "unset" => None,
_ => style.text_wrap_style,
};
}
"text-wrap-style" => {
style.text_wrap_style = match value.trim().to_ascii_lowercase().as_str() {
"auto" | "initial" | "revert" | "revert-layer" => {
Some(crate::TextWrapStyle::Auto)
}
"balance" => Some(crate::TextWrapStyle::Balance),
// `text-wrap-style` inherits, so unset behaves as inherit.
"inherit" | "unset" => None,
_ => style.text_wrap_style,
};
}
"font-style" => {
let v = value.trim().to_ascii_lowercase();
style.font_style_italic = Some(v.starts_with("italic") || v.starts_with("oblique"));
}
"text-transform" => {
style.text_transform = Some(match value.trim().to_ascii_lowercase().as_str() {
"uppercase" => crate::TextTransform::Uppercase,
"lowercase" => crate::TextTransform::Lowercase,
"capitalize" => crate::TextTransform::Capitalize,
_ => crate::TextTransform::None,
});
}
"text-decoration" | "text-decoration-line" => {
// Shorthand can carry color/style/thickness; we only model the
// underline line (the dominant case, and the UA default for links).
let toks: Vec<String> = value.split_whitespace().map(|t| t.to_ascii_lowercase()).collect();
let underline = toks.iter().any(|t| t == "underline");
let none = toks.iter().any(|t| t == "none");
style.underline = Some(underline && !none);
}
"gap" | "grid-gap" => {
let values = split_ws_paren(value);
if let Some(row) = values.first() {
apply_gap_value(style, true, row);
apply_gap_value(style, false, values.get(1).copied().unwrap_or(row));
}
}
"row-gap" | "grid-row-gap" => apply_gap_value(style, true, value),
"column-gap" | "grid-column-gap" | "-webkit-column-gap" => {
apply_gap_value(style, false, value)
}
"column-count" | "-webkit-column-count" => {
style.column_count = parse_column_count(value);
}
"columns" | "-webkit-columns" => {
// `columns` is `column-width || column-count`; width remains auto
// in our box-fragmentation subset, but the shorthand still resets
// an earlier count when it contains no positive integer.
style.column_count = split_ws_paren(value)
.into_iter()
.find_map(parse_column_count);
}
"break-inside" => {
style.break_inside_avoid =
matches!(value.trim().to_ascii_lowercase().as_str(), "avoid" | "avoid-column");
}
"-webkit-column-break-inside" => {
style.break_inside_avoid = value.trim().eq_ignore_ascii_case("avoid");
}
"border-spacing" => {
let dims: Vec<f32> = value.split_whitespace().filter_map(px_value).collect();
if let Some(&h) = dims.first() {
style.border_spacing = Some((h, *dims.get(1).unwrap_or(&h)));
}
}
"border-collapse" => {
style.border_collapse = match value.trim().to_ascii_lowercase().as_str() {
"collapse" => Some(true),
"separate" | "initial" | "revert" | "revert-layer" => Some(false),
// This is an inherited property, so both an omitted value and
// an explicit inherit/unset are resolved in the top-down pass.
"inherit" | "unset" => None,
_ => style.border_collapse,
};
}
"grid-template-columns" => {
let (tracks, names) = parse_track_list_named(value);
style.grid_template_columns_subgrid = is_subgrid_track_list(value);
style.grid_template_columns = tracks;
style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names));
}
"grid-template-rows" => {
let (tracks, names) = parse_track_list_named(value);
style.grid_template_rows = tracks;
style.grid_row_line_names = (!names.is_empty()).then(|| build_line_map(names));
}
"grid-template-areas" => style.grid_areas = Some(parse_grid_areas(value)),
"grid-template" => parse_grid_template(style, value),
"grid" => parse_grid_shorthand(style, value),
"grid-auto-flow" => style.grid_auto_flow = parse_grid_auto_flow(value),
"grid-area" => set_grid_area(style, value),
"grid-column" => set_grid_placement(style, value, true),
"grid-row" => set_grid_placement(style, value, false),
"grid-column-start" => set_grid_placement_side(style, value, true, true),
"grid-column-end" => set_grid_placement_side(style, value, true, false),
"grid-row-start" => set_grid_placement_side(style, value, false, true),
"grid-row-end" => set_grid_placement_side(style, value, false, false),
"transform" => parse_transform(style, value),
"transform-origin" => style.transform_origin = parse_transform_origin(value),
"translate" => parse_individual_translate(style, value),
"scale" => parse_individual_scale(style, value),
"filter" => set_containing_block_trigger(
style,
crate::CB_TRIGGER_FILTER,
non_none_value(value),
),
"backdrop-filter" | "-webkit-backdrop-filter" => set_containing_block_trigger(
style,
crate::CB_TRIGGER_BACKDROP_FILTER,
non_none_value(value),
),
"perspective" => set_containing_block_trigger(
style,
crate::CB_TRIGGER_PERSPECTIVE,
non_none_value(value),
),
"contain" => {
let establishes = value
.split_whitespace()
.any(|v| matches!(v.to_ascii_lowercase().as_str(), "layout" | "paint" | "strict" | "content"));
set_containing_block_trigger(style, crate::CB_TRIGGER_CONTAIN, establishes);
}
"will-change" => {
let establishes = value
.split([',', ' '])
.map(str::trim)
.any(|v| matches!(v.to_ascii_lowercase().as_str(), "transform" | "filter" | "backdrop-filter" | "perspective" | "contain"));
set_containing_block_trigger(style, crate::CB_TRIGGER_WILL_CHANGE, establishes);
}
"content-visibility" => set_containing_block_trigger(
style,
crate::CB_TRIGGER_CONTENT_VISIBILITY,
value.trim().eq_ignore_ascii_case("auto"),
),
"box-shadow" | "-webkit-box-shadow" => {
style.box_shadow = parse_box_shadow(
value,
style.color,
style.color_scheme_dark,
);
}
_ => {}
}
}
/// Whether a declaration is syntactically supported by the renderer's CSS
/// property/value implementation. `@supports` and the JavaScript-facing
/// capability model must not treat every non-empty declaration as valid:
/// 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 {
let name = name.trim().to_ascii_lowercase();
let value = value.trim();
if value.is_empty() {
return false;
}
if name.starts_with("--") {
return name.len() > 2;
}
let css_wide = matches!(
value.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
);
let known = matches!(
name.as_str(),
"display"
| "width"
| "height"
| "min-width"
| "min-height"
| "max-width"
| "max-height"
| "box-sizing"
| "container"
| "container-type"
| "container-name"
| "aspect-ratio"
| "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"
| "padding-block"
| "padding-block-start"
| "padding-block-end"
| "border-radius"
| "clip-path"
| "-webkit-clip-path"
| "border"
| "border-width"
| "border-top-width"
| "border-right-width"
| "border-bottom-width"
| "border-left-width"
| "border-top"
| "border-right"
| "border-bottom"
| "border-left"
| "background"
| "background-color"
| "background-image"
| "background-size"
| "background-position"
| "background-repeat"
| "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"
| "color-scheme"
| "font-size"
| "letter-spacing"
| "font"
| "font-weight"
| "font-family"
| "font-style"
| "font-optical-sizing"
| "font-variation-settings"
| "text-align"
| "text-indent"
| "text-transform"
| "text-decoration"
| "text-decoration-line"
| "line-height"
| "white-space"
| "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"
| "counter-reset"
| "counter-increment"
| "counter-set"
| "object-fit"
| "top"
| "right"
| "bottom"
| "left"
| "inset"
| "inset-inline"
| "inset-inline-start"
| "inset-inline-end"
| "inset-block"
| "inset-block-start"
| "inset-block-end"
| "overflow"
| "overflow-x"
| "overflow-y"
| "scrollbar-gutter"
| "visibility"
| "opacity"
| "animation"
| "animation-name"
| "animation-duration"
| "animation-delay"
| "animation-fill-mode"
| "animation-iteration-count"
| "animation-direction"
| "animation-play-state"
| "z-index"
| "clear"
| "vertical-align"
| "list-style"
| "list-style-type"
| "gap"
| "grid-gap"
| "row-gap"
| "grid-row-gap"
| "column-gap"
| "grid-column-gap"
| "-webkit-column-gap"
| "column-count"
| "-webkit-column-count"
| "columns"
| "-webkit-columns"
| "break-inside"
| "-webkit-column-break-inside"
| "border-spacing"
| "border-collapse"
| "grid-template-columns"
| "grid-template-rows"
| "grid-template-areas"
| "grid-template"
| "grid"
| "grid-auto-flow"
| "grid-area"
| "grid-column"
| "grid-row"
| "grid-column-start"
| "grid-column-end"
| "grid-row-start"
| "grid-row-end"
| "transform"
| "transform-origin"
| "translate"
| "scale"
| "filter"
| "backdrop-filter"
| "-webkit-backdrop-filter"
| "perspective"
| "contain"
| "will-change"
| "content-visibility"
| "box-shadow"
| "-webkit-box-shadow"
);
if !known {
return false;
}
if css_wide {
return true;
}
match name.as_str() {
"display" => matches!(
value.to_ascii_lowercase().as_str(),
"none"
| "flex"
| "inline-flex"
| "inline"
| "inline-block"
| "grid"
| "inline-grid"
| "block"
| "flow-root"
| "contents"
),
"position" => matches!(
value.to_ascii_lowercase().as_str(),
"static" | "relative" | "absolute" | "fixed" | "sticky"
),
"box-sizing" => matches!(
value.to_ascii_lowercase().as_str(),
"content-box" | "border-box"
),
"container-type" => parse_container_type(value).is_some(),
"container-name" => parse_container_names(value).is_some(),
"container" => parse_container_shorthand(value).is_some(),
"font-optical-sizing" => matches!(
value.to_ascii_lowercase().as_str(),
"auto" | "none"
),
"font-variation-settings" => {
value.eq_ignore_ascii_case("normal")
|| parse_font_variation_settings(value).is_some()
}
"white-space" => matches!(
value.to_ascii_lowercase().as_str(),
"normal" | "nowrap" | "pre" | "pre-wrap" | "pre-line" | "break-spaces"
),
"text-wrap" => matches!(
value.to_ascii_lowercase().as_str(),
"auto" | "wrap" | "balance" | "wrap balance" | "balance wrap"
),
"text-wrap-style" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "balance"),
"text-indent" => {
parse_text_indent(value).is_some()
}
"animation-duration" => parse_animation_time_ms(value).is_some_and(|time| time >= 0.0),
"animation-delay" => parse_animation_time_ms(value).is_some(),
"animation-iteration-count" => parse_animation_iteration_count(value).is_some(),
"animation-direction" => parse_animation_direction(value).is_some(),
"clip-path" | "-webkit-clip-path" => {
value.eq_ignore_ascii_case("none") || parse_clip_path_polygon(value).is_some()
}
"animation-fill-mode" => parse_animation_fill_mode(value).is_some(),
"animation-play-state" => parse_animation_play_state(value).is_some(),
"float" => matches!(
value.to_ascii_lowercase().as_str(),
"none" | "left" | "right"
),
"object-fit" => matches!(
value.to_ascii_lowercase().as_str(),
"fill" | "contain" | "cover" | "none" | "scale-down"
),
"visibility" => matches!(
value.to_ascii_lowercase().as_str(),
"visible" | "hidden" | "collapse"
),
"scrollbar-gutter" => matches!(
value.to_ascii_lowercase().as_str(),
"auto" | "stable" | "stable both-edges"
),
"overflow" | "overflow-x" | "overflow-y" => {
parse_overflow_declaration(&name, value).is_some()
}
"grid-area" => {
let parts = split_top_level(value, '/')
.into_iter()
.map(str::trim)
.collect::<Vec<_>>();
!parts.is_empty()
&& parts.len() <= 4
&& parts.iter().all(|part| {
!part.is_empty() && parse_grid_line_kind(part).is_some()
})
}
"color" | "-webkit-text-fill-color" | "background-color" | "border-color" => {
parse_color(value).is_some()
}
"color-scheme" => {
let tokens: Vec<&str> = value.split_whitespace().collect();
!tokens.is_empty()
&& tokens.iter().all(|token| {
matches!(
token.to_ascii_lowercase().as_str(),
"normal" | "light" | "dark" | "only"
)
})
&& tokens.iter().any(|token| {
matches!(
token.to_ascii_lowercase().as_str(),
"normal" | "light" | "dark"
)
})
}
_ => true,
}
}
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() {
return;
}
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_name = None;
style.animation_timing = crate::AnimationTiming::default();
return;
}
let mut name = None;
let mut timing = crate::AnimationTiming::default();
let mut time_count = 0usize;
for token in split_ws_paren(first) {
let lower = token.to_ascii_lowercase();
let timing_keyword = matches!(
lower.as_str(),
"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out"
) || lower.starts_with("cubic-bezier(")
|| lower.starts_with("steps(")
|| lower.starts_with("linear(");
if let Some(time) = parse_animation_time_ms(&lower) {
if time_count == 0 {
if time < 0.0 {
return;
}
timing.duration_ms = time;
} else if time_count == 1 {
timing.delay_ms = time;
} else {
return;
}
time_count += 1;
} else if let Some(direction) = parse_animation_direction(&lower) {
timing.direction = direction;
} else if let Some(fill) = parse_animation_fill_mode(&lower) {
timing.fill_mode = fill;
} else if let Some(play_state) = parse_animation_play_state(&lower) {
timing.play_state = play_state;
} else if let Some(iterations) = parse_animation_iteration_count(&lower) {
timing.iteration_count = iterations;
} else if timing_keyword {
continue;
} else if lower == "none" {
if name.is_some() {
return;
}
} else if name.is_none() {
name = Some(token.to_string());
} else {
return;
}
}
style.animation_name = name;
style.animation_timing = timing;
}
fn parse_animation_direction(value: &str) -> Option<crate::AnimationDirection> {
match value.trim().to_ascii_lowercase().as_str() {
"normal" => Some(crate::AnimationDirection::Normal),
"reverse" => Some(crate::AnimationDirection::Reverse),
"alternate" => Some(crate::AnimationDirection::Alternate),
"alternate-reverse" => Some(crate::AnimationDirection::AlternateReverse),
_ => None,
}
}
fn parse_animation_fill_mode(value: &str) -> Option<crate::AnimationFillMode> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(crate::AnimationFillMode::None),
"forwards" => Some(crate::AnimationFillMode::Forwards),
"backwards" => Some(crate::AnimationFillMode::Backwards),
"both" => Some(crate::AnimationFillMode::Both),
_ => None,
}
}
fn parse_animation_play_state(value: &str) -> Option<crate::AnimationPlayState> {
match value.trim().to_ascii_lowercase().as_str() {
"running" => Some(crate::AnimationPlayState::Running),
"paused" => Some(crate::AnimationPlayState::Paused),
_ => None,
}
}
fn parse_animation_iteration_count(value: &str) -> Option<f32> {
let value = value.trim();
if value.eq_ignore_ascii_case("infinite") {
return Some(f32::INFINITY);
}
value.parse::<f32>().ok().filter(|count| count.is_finite() && *count >= 0.0)
}
#[derive(Clone, Copy)]
enum AnimationCalcValue {
Number(f32),
Milliseconds(f32),
}
impl AnimationCalcValue {
fn negated(self) -> Self {
match self {
Self::Number(value) => Self::Number(-value),
Self::Milliseconds(value) => Self::Milliseconds(-value),
}
}
fn add(self, other: Self, subtract: bool) -> Option<Self> {
let sign = if subtract { -1.0 } else { 1.0 };
match (self, other) {
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left + sign * right)),
(Self::Milliseconds(left), Self::Milliseconds(right)) => {
Some(Self::Milliseconds(left + sign * right))
}
_ => None,
}
}
fn multiply(self, other: Self) -> Option<Self> {
match (self, other) {
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left * right)),
(Self::Milliseconds(time), Self::Number(number))
| (Self::Number(number), Self::Milliseconds(time)) => {
Some(Self::Milliseconds(time * number))
}
_ => None,
}
}
fn divide(self, other: Self) -> Option<Self> {
match (self, other) {
(_, Self::Number(0.0)) => None,
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left / right)),
(Self::Milliseconds(time), Self::Number(number)) => {
Some(Self::Milliseconds(time / number))
}
(Self::Milliseconds(left), Self::Milliseconds(right)) if right != 0.0 => {
Some(Self::Number(left / right))
}
_ => None,
}
}
}
struct AnimationCalcParser<'a> {
input: &'a [u8],
position: usize,
}
impl AnimationCalcParser<'_> {
fn skip_whitespace(&mut self) {
while self.input.get(self.position).is_some_and(u8::is_ascii_whitespace) {
self.position += 1;
}
}
fn consume(&mut self, byte: u8) -> bool {
self.skip_whitespace();
if self.input.get(self.position) == Some(&byte) {
self.position += 1;
true
} else {
false
}
}
fn sum(&mut self) -> Option<AnimationCalcValue> {
let mut value = self.product()?;
loop {
if self.consume(b'+') {
value = value.add(self.product()?, false)?;
} else if self.consume(b'-') {
value = value.add(self.product()?, true)?;
} else {
return Some(value);
}
}
}
fn product(&mut self) -> Option<AnimationCalcValue> {
let mut value = self.unary()?;
loop {
if self.consume(b'*') {
value = value.multiply(self.unary()?)?;
} else if self.consume(b'/') {
value = value.divide(self.unary()?)?;
} else {
return Some(value);
}
}
}
fn unary(&mut self) -> Option<AnimationCalcValue> {
if self.consume(b'+') {
self.unary()
} else if self.consume(b'-') {
Some(self.unary()?.negated())
} else {
self.primary()
}
}
fn primary(&mut self) -> Option<AnimationCalcValue> {
if self.consume(b'(') {
let value = self.sum()?;
self.consume(b')').then_some(value)
} else {
self.number()
}
}
fn number(&mut self) -> Option<AnimationCalcValue> {
self.skip_whitespace();
let start = self.position;
let mut saw_digit = false;
while let Some(byte) = self.input.get(self.position) {
if byte.is_ascii_digit() {
saw_digit = true;
self.position += 1;
} else if *byte == b'.' {
self.position += 1;
} else {
break;
}
}
if !saw_digit {
return None;
}
let number = std::str::from_utf8(&self.input[start..self.position])
.ok()?
.parse::<f32>()
.ok()?;
if self.input.get(self.position..self.position + 2) == Some(b"ms") {
self.position += 2;
Some(AnimationCalcValue::Milliseconds(number))
} else if self.input.get(self.position) == Some(&b's') {
self.position += 1;
Some(AnimationCalcValue::Milliseconds(number * 1000.0))
} else {
Some(AnimationCalcValue::Number(number))
}
}
}
pub(crate) fn parse_animation_time_ms(value: &str) -> Option<f32> {
let normalized = value.trim().to_ascii_lowercase();
let is_calc = normalized.starts_with("calc(") && normalized.ends_with(')');
if !is_calc
&& normalized
.bytes()
.any(|byte| matches!(byte, b'+' | b'*' | b'/' | b'(' | b')'))
{
return None;
}
let expression = if is_calc {
&normalized[5..normalized.len() - 1]
} else {
normalized.as_str()
};
let mut parser = AnimationCalcParser {
input: expression.as_bytes(),
position: 0,
};
let result = parser.sum()?;
parser.skip_whitespace();
if parser.position != parser.input.len() {
return None;
}
match result {
AnimationCalcValue::Milliseconds(milliseconds) if milliseconds.is_finite() => {
Some(milliseconds)
}
_ => None,
}
}
/// Parse the common CSS Box Alignment values that taffy can represent.
///
/// The outer option distinguishes an invalid declaration from `auto`, which
/// resets a preceding declaration to the inherited item-alignment behavior.
fn self_alignment_value(value: &str) -> Option<Option<taffy::AlignSelf>> {
let normalized = value.trim().to_ascii_lowercase();
let alignment = match normalized.as_str() {
"auto" => return Some(None),
"normal" => taffy::AlignSelf::STRETCH,
"start" | "self-start" => taffy::AlignSelf::START,
"end" | "self-end" => taffy::AlignSelf::END,
"flex-start" => taffy::AlignSelf::FLEX_START,
"flex-end" => taffy::AlignSelf::FLEX_END,
"center" => taffy::AlignSelf::CENTER,
"baseline" | "first baseline" => taffy::AlignSelf::BASELINE,
"stretch" => taffy::AlignSelf::STRETCH,
"safe start" | "safe self-start" => taffy::AlignSelf::SAFE_START,
"safe end" | "safe self-end" => taffy::AlignSelf::SAFE_END,
"safe flex-start" => taffy::AlignSelf::SAFE_FLEX_START,
"safe flex-end" => taffy::AlignSelf::SAFE_FLEX_END,
"safe center" => taffy::AlignSelf::SAFE_CENTER,
"unsafe start" | "unsafe self-start" => taffy::AlignSelf::START,
"unsafe end" | "unsafe self-end" => taffy::AlignSelf::END,
"unsafe flex-start" => taffy::AlignSelf::FLEX_START,
"unsafe flex-end" => taffy::AlignSelf::FLEX_END,
"unsafe center" => taffy::AlignSelf::CENTER,
_ => return None,
};
Some(Some(alignment))
}
fn self_alignment_pair(
value: &str,
) -> Option<(Option<taffy::AlignSelf>, Option<taffy::JustifySelf>)> {
if let Some(alignment) = self_alignment_value(value) {
return Some((alignment, alignment));
}
let tokens: Vec<&str> = value.split_whitespace().collect();
for split in 1..tokens.len() {
let align = tokens[..split].join(" ");
let justify = tokens[split..].join(" ");
if let (Some(align), Some(justify)) = (
self_alignment_value(&align),
self_alignment_value(&justify),
) {
return Some((align, justify));
}
}
None
}
fn content_alignment_value(value: &str) -> Option<taffy::AlignContent> {
let normalized = value.trim().to_ascii_lowercase();
Some(match normalized.as_str() {
"normal" | "stretch" => taffy::AlignContent::STRETCH,
"start" => taffy::AlignContent::START,
"end" => taffy::AlignContent::END,
"flex-start" => taffy::AlignContent::FLEX_START,
"flex-end" => taffy::AlignContent::FLEX_END,
"center" => taffy::AlignContent::CENTER,
"space-between" => taffy::AlignContent::SPACE_BETWEEN,
"space-around" => taffy::AlignContent::SPACE_AROUND,
"space-evenly" => taffy::AlignContent::SPACE_EVENLY,
"safe start" => taffy::AlignContent::SAFE_START,
"safe end" => taffy::AlignContent::SAFE_END,
"safe flex-start" => taffy::AlignContent::SAFE_FLEX_START,
"safe flex-end" => taffy::AlignContent::SAFE_FLEX_END,
"safe center" => taffy::AlignContent::SAFE_CENTER,
"unsafe start" => taffy::AlignContent::START,
"unsafe end" => taffy::AlignContent::END,
"unsafe flex-start" => taffy::AlignContent::FLEX_START,
"unsafe flex-end" => taffy::AlignContent::FLEX_END,
"unsafe center" => taffy::AlignContent::CENTER,
_ => return None,
})
}
fn content_alignment_pair(
value: &str,
) -> Option<(taffy::AlignContent, taffy::JustifyContent)> {
if let Some(alignment) = content_alignment_value(value) {
return Some((alignment, alignment));
}
let tokens: Vec<&str> = value.split_whitespace().collect();
for split in 1..tokens.len() {
let align = tokens[..split].join(" ");
let justify = tokens[split..].join(" ");
if let (Some(align), Some(justify)) = (
content_alignment_value(&align),
content_alignment_value(&justify),
) {
return Some((align, justify));
}
}
None
}
/// Parse the transform functions used by the scoped paint model. Translation
/// stays unresolved until the final border box is known. Scale is captured
/// separately, while rotations are multiplied as 3×3 matrices and projected
/// onto the input z=0 plane; this exactly represents rotateX/Y/Z chains that
/// do not introduce perspective.
fn parse_transform(style: &mut LayoutStyle, value: &str) {
let v = value.trim();
if v.is_empty() {
return;
}
// `transform: none` resets any transform from a lower-priority rule (the
// carousel idiom: a class translates the track, an inline style clears it
// for the untransformed state).
if v.eq_ignore_ascii_case("none") {
style.transform_translate = None;
style.transform_scale = None;
style.transform_projection = None;
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, false);
return;
}
let functions = transform_functions(v);
if functions.is_empty() {
return;
}
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true);
let zero = crate::Dimension::Px(0.0);
let (mut tx, mut ty): (Option<crate::Dimension>, Option<crate::Dimension>) = (None, None);
let (mut sx, mut sy): (Option<f32>, Option<f32>) = (None, None);
let mut rotation = [
1.0f32, 0.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0,
];
let mut has_rotation = false;
for (func, args) in functions {
let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
match func.to_ascii_lowercase().as_str() {
// translate3d's z component is ignored (no perspective model);
// Swiper and similar carousels position their track exclusively
// through translate3d, so dropping it left every slide stacked.
"translate" | "translate3d" => {
if let Some(a) = parts.first() {
tx = Some(dimension_value(a));
}
// A missing second component is 0 (translateY), not inherited.
ty = Some(parts.get(1).map(|a| dimension_value(a)).unwrap_or(zero));
}
"translatex" => {
if let Some(a) = parts.first() {
tx = Some(dimension_value(a));
}
}
"translatey" => {
if let Some(a) = parts.first() {
ty = Some(dimension_value(a));
}
}
"scale" => {
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
// scale(s) is uniform; scale(sx, sy) sets each axis.
sy = Some(parts.get(1).and_then(|s| scale_number(s)).unwrap_or(a));
sx = Some(a);
}
}
"scalex" => {
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
sx = Some(a);
}
}
"scaley" => {
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
sy = Some(a);
}
}
"rotate" | "rotatez" => {
if let Some(angle) = parts.first().and_then(|value| angle_degrees(value)) {
rotation = matrix3_mul(rotation, rotate_z_matrix(angle));
has_rotation = true;
}
}
"rotatex" => {
if let Some(angle) = parts.first().and_then(|value| angle_degrees(value)) {
rotation = matrix3_mul(rotation, rotate_x_matrix(angle));
has_rotation = true;
}
}
"rotatey" => {
if let Some(angle) = parts.first().and_then(|value| angle_degrees(value)) {
rotation = matrix3_mul(rotation, rotate_y_matrix(angle));
has_rotation = true;
}
}
// Skew, matrix, and perspective remain outside the scoped affine
// image projection. Other supported functions in the same value
// still take effect.
_ => {}
}
}
if tx.is_some() || ty.is_some() {
style.transform_translate = Some((tx.unwrap_or(zero), ty.unwrap_or(zero)));
}
if sx.is_some() || sy.is_some() {
style.transform_scale = Some((sx.unwrap_or(1.0), sy.unwrap_or(1.0)));
}
style.transform_projection = has_rotation.then_some([
rotation[0],
rotation[3],
rotation[1],
rotation[4],
]);
}
fn matrix3_mul(left: [f32; 9], right: [f32; 9]) -> [f32; 9] {
let mut out = [0.0; 9];
for row in 0..3 {
for column in 0..3 {
out[row * 3 + column] = (0..3)
.map(|inner| left[row * 3 + inner] * right[inner * 3 + column])
.sum();
}
}
out
}
fn rotate_x_matrix(angle: f32) -> [f32; 9] {
let (sin, cos) = angle.to_radians().sin_cos();
[1.0, 0.0, 0.0, 0.0, cos, -sin, 0.0, sin, cos]
}
fn rotate_y_matrix(angle: f32) -> [f32; 9] {
let (sin, cos) = angle.to_radians().sin_cos();
[cos, 0.0, sin, 0.0, 1.0, 0.0, -sin, 0.0, cos]
}
fn rotate_z_matrix(angle: f32) -> [f32; 9] {
let (sin, cos) = angle.to_radians().sin_cos();
[cos, -sin, 0.0, sin, cos, 0.0, 0.0, 0.0, 1.0]
}
fn angle_degrees(value: &str) -> Option<f32> {
fn primitive(value: &str) -> Option<f32> {
let value = value.trim().to_ascii_lowercase();
if let Some(number) = value.strip_suffix("deg") {
number.trim().parse().ok()
} else if let Some(number) = value.strip_suffix("grad") {
number.trim().parse::<f32>().ok().map(|value| value * 0.9)
} else if let Some(number) = value.strip_suffix("rad") {
number
.trim()
.parse::<f32>()
.ok()
.map(f32::to_degrees)
} else if let Some(number) = value.strip_suffix("turn") {
number.trim().parse::<f32>().ok().map(|value| value * 360.0)
} else if value == "0" {
Some(0.0)
} else {
None
}
}
let value = value.trim();
let Some(inner) = value
.strip_prefix("calc(")
.and_then(|value| value.strip_suffix(')'))
else {
return primitive(value);
};
if let Some((angle, factor)) = inner.split_once('*') {
return Some(primitive(angle)? * factor.trim().parse::<f32>().ok()?);
}
if let Some((angle, divisor)) = inner.split_once('/') {
let divisor = divisor.trim().parse::<f32>().ok()?;
return (divisor != 0.0).then(|| primitive(angle).map(|angle| angle / divisor)).flatten();
}
primitive(inner)
}
fn parse_transform_origin(value: &str) -> Option<(crate::Dimension, crate::Dimension)> {
let mut x = None;
let mut y = None;
for token in value.split_whitespace().take(2) {
match token.to_ascii_lowercase().as_str() {
"left" => x = Some(crate::Dimension::Percent(0.0)),
"right" => x = Some(crate::Dimension::Percent(1.0)),
"top" => y = Some(crate::Dimension::Percent(0.0)),
"bottom" => y = Some(crate::Dimension::Percent(1.0)),
"center" if x.is_none() => x = Some(crate::Dimension::Percent(0.5)),
"center" => y = Some(crate::Dimension::Percent(0.5)),
_ if x.is_none() => x = Some(dimension_value(token)),
_ => y = Some(dimension_value(token)),
}
}
if x.is_none() && y.is_none() {
return None;
}
Some((
x.unwrap_or(crate::Dimension::Percent(0.5)),
y.unwrap_or(crate::Dimension::Percent(0.5)),
))
}
fn parse_individual_scale(style: &mut LayoutStyle, value: &str) {
if value.trim().eq_ignore_ascii_case("none") {
style.transform_scale = None;
return;
}
let values: Vec<f32> = value
.split_whitespace()
.take(2)
.filter_map(scale_number)
.collect();
let Some(&x) = values.first() else { return };
let y = values.get(1).copied().unwrap_or(x);
style.transform_scale = Some((x, y));
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true);
}
fn parse_individual_translate(style: &mut LayoutStyle, value: &str) {
let value = value.trim();
if matches!(
value.to_ascii_lowercase().as_str(),
"none" | "initial" | "unset" | "revert" | "revert-layer"
) {
style.individual_translate = None;
style.individual_translate_expressions = [None, None];
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSLATE, false);
return;
}
let values = split_ws_paren(value);
if values.is_empty() || values.len() > 3 {
return;
}
let component = |token: &str| {
if token.contains('(') {
Some((crate::Dimension::Px(0.0), Some(token.trim().to_string())))
} else {
let dimension = dimension_value(token);
(!matches!(dimension, crate::Dimension::Auto)).then_some((dimension, None))
}
};
let Some((x, x_expression)) = component(values[0]) else {
return;
};
let Some((y, y_expression)) = values
.get(1)
.map(|value| component(value))
.unwrap_or_else(|| Some((crate::Dimension::Px(0.0), None)))
else {
return;
};
// The optional third component is a z translation. The scoped renderer is
// two-dimensional, but accepting a valid value still preserves x/y like
// transform:translate3d() does.
if values
.get(2)
.is_some_and(|value| component(value).is_none())
{
return;
}
style.individual_translate = Some((x, y));
style.individual_translate_expressions = [x_expression, y_expression];
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSLATE, true);
}
fn non_none_value(value: &str) -> bool {
let value = value.trim();
!value.is_empty() && !value.eq_ignore_ascii_case("none")
}
fn set_containing_block_trigger(style: &mut LayoutStyle, trigger: u16, enabled: bool) {
if enabled {
style.containing_block_triggers |= trigger;
} else {
style.containing_block_triggers &= !trigger;
}
}
/// Split a `transform` value into its `name(args)` functions, in source order.
/// Tokenizes on parentheses (tracking depth so a nested `calc(...)` inside an
/// argument stays intact); the function name is the trailing identifier run
/// just before each `(`.
fn transform_functions(value: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut rest = value;
while let Some(open) = rest.find('(') {
let name: String = rest[..open]
.chars()
.rev()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let after = &rest[open + 1..];
let mut depth = 1i32;
let mut end = None;
for (i, c) in after.char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
end = Some(i);
break;
}
}
_ => {}
}
}
let Some(e) = end else { break };
if !name.is_empty() {
out.push((name, after[..e].to_string()));
}
rest = &after[e + 1..];
}
out
}
/// Parse a unitless scale factor. A trailing `%` (`scale(50%)`) is accepted and
/// divided by 100, matching the individual `scale` property's percentage form.
fn scale_number(s: &str) -> Option<f32> {
let t = s.trim();
if let Some(p) = t.strip_suffix('%') {
return p.trim().parse::<f32>().ok().map(|v| v / 100.0);
}
t.parse::<f32>().ok()
}
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
/// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the
/// parentheses in `minmax(...)` / `fit-content(...)`.
/// Parse a track list into taffy sizing functions plus the `[line-name]` map
/// (name -> 1-based grid line number). `repeat(n, ...)` is expanded to n copies;
/// auto-fill/auto-fit remain typed repetitions so layout can derive their count
/// from the used container width. `[name]` annotations are captured rather than
/// turned into tracks. First occurrence of a name wins.
pub(crate) fn parse_track_list_named(
value: &str,
) -> (
Vec<taffy::GridTemplateComponent<String>>,
Vec<(String, i16)>,
) {
let tokens = tokenize_tracks(value);
let mut tracks = Vec::new();
let mut names = Vec::new();
let mut line: i16 = 1;
// A subgridded axis owns line names but no sizing functions. Do not turn
// the keyword into the generic unknown-token => auto-track fallback.
let is_subgrid = tokens
.first()
.map(|token| token.eq_ignore_ascii_case("subgrid"))
.unwrap_or(false);
for tok in tokens.into_iter().skip(usize::from(is_subgrid)) {
expand_track_token(&tok, &mut tracks, &mut names, &mut line);
}
(tracks, names)
}
fn expand_track_token(
tok: &str,
tracks: &mut Vec<taffy::GridTemplateComponent<String>>,
names: &mut Vec<(String, i16)>,
line: &mut i16,
) {
let t = tok.trim();
if t.starts_with('[') {
let inner = t.trim_start_matches('[').trim_end_matches(']');
for name in inner.split_whitespace() {
names.push((name.to_string(), *line));
}
return;
}
let lower = t.to_ascii_lowercase();
if lower.starts_with("repeat(") && t.ends_with(')') {
let inner = &t["repeat(".len()..t.len() - 1];
if let Some((cnt, sub)) = inner.split_once(',') {
let subtoks = tokenize_tracks(sub.trim());
let repetition = match cnt.trim().to_ascii_lowercase().as_str() {
"auto-fill" => Some(taffy::RepetitionCount::AutoFill),
"auto-fit" => Some(taffy::RepetitionCount::AutoFit),
_ => None,
};
if let Some(count) = repetition {
let repeated_tracks = subtoks
.iter()
.filter(|st| !st.trim_start().starts_with('['))
.map(|st| track(st))
.collect();
tracks.push(taffy::GridTemplateComponent::Repeat(
taffy::GridTemplateRepetition {
count,
tracks: repeated_tracks,
line_names: Vec::new(),
},
));
*line += 1;
return;
}
let count = cnt.trim().parse::<usize>().unwrap_or(1).min(1000);
for _ in 0..count {
for st in &subtoks {
expand_track_token(st, tracks, names, line);
}
}
}
return;
}
tracks.push(taffy::GridTemplateComponent::Single(track(t)));
*line += 1;
}
pub(crate) fn build_line_map(pairs: Vec<(String, i16)>) -> std::collections::HashMap<String, i16> {
let mut m = std::collections::HashMap::new();
for (name, line) in pairs {
m.entry(name).or_insert(line);
}
m
}
/// Split a track list on whitespace while keeping `func(a, b)` groups and
/// `[line-name lists]` intact.
fn tokenize_tracks(value: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut depth = 0i32;
let mut in_bracket = false;
for c in value.chars() {
match c {
'[' => { in_bracket = true; cur.push(c); }
']' => { in_bracket = false; cur.push(c); }
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
c if c.is_whitespace() && depth == 0 && !in_bracket => {
if !cur.is_empty() { out.push(std::mem::take(&mut cur)); }
}
c => cur.push(c),
}
}
if !cur.is_empty() { out.push(cur); }
out
}
fn track(tok: &str) -> taffy::TrackSizingFunction {
use taffy::MinMax;
let t = tok.trim();
let lower = t.to_ascii_lowercase();
if let Some(inner) = lower.strip_prefix("minmax(").and_then(|s| s.strip_suffix(')')) {
if let Some((a, b)) = inner.split_once(',') {
return MinMax { min: min_track(a.trim()), max: max_track(b.trim()) };
}
}
if let Some(inner) = lower.strip_prefix("fit-content(").and_then(|s| s.strip_suffix(')')) {
let px = px_value(inner.trim()).unwrap_or(0.0);
return MinMax {
min: taffy::MinTrackSizingFunction::auto(),
max: taffy::MaxTrackSizingFunction::fit_content_px(px),
};
}
MinMax { min: min_track(t), max: max_track(t) }
}
fn min_track(tok: &str) -> taffy::MinTrackSizingFunction {
use taffy::MinTrackSizingFunction as M;
let lower = tok.to_ascii_lowercase();
match lower.as_str() {
"min-content" => M::min_content(),
"max-content" => M::max_content(),
"auto" => M::auto(),
other => {
if other.ends_with("fr") {
// Flexible tracks have an automatic minimum.
M::auto()
} else if let Some(p) = other.strip_suffix('%').and_then(|n| n.trim().parse::<f32>().ok()) {
M::percent(p / 100.0)
} else if let Some(px) = px_value(other) {
M::length(px)
} else {
M::auto()
}
}
}
}
fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction {
use taffy::MaxTrackSizingFunction as M;
let lower = tok.to_ascii_lowercase();
match lower.as_str() {
"min-content" => M::min_content(),
"max-content" => M::max_content(),
"auto" => M::auto(),
other => {
if let Some(fr) = other.strip_suffix("fr").and_then(|n| n.trim().parse::<f32>().ok()) {
M::fr(fr)
} else if let Some(p) = other.strip_suffix('%').and_then(|n| n.trim().parse::<f32>().ok()) {
M::percent(p / 100.0)
} else if let Some(px) = px_value(other) {
M::length(px)
} else {
M::auto()
}
}
}
}
/// Parse `grid-template-areas: 'a a' 'b c'` into a matrix of cell names.
fn parse_grid_areas(value: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut in_str = false;
let mut cur = String::new();
for c in value.chars() {
match c {
'\'' | '"' => {
if in_str {
rows.push(cur.split_whitespace().map(|s| s.to_string()).collect::<Vec<_>>());
cur.clear();
in_str = false;
} else {
in_str = true;
}
}
_ if in_str => cur.push(c),
_ => {}
}
}
rows
}
/// Parse the `grid-template: <rows> / <cols>` shorthand. The rows side may embed
/// area strings; we support the common `tracks / tracks` form and, when area
/// strings are present, extract them too.
fn parse_grid_template(style: &mut LayoutStyle, value: &str) {
let (rows_part, cols_part) = match value.split_once('/') {
Some((r, c)) => (r.trim(), Some(c.trim())),
None => (value.trim(), None),
};
if rows_part.contains('\'') || rows_part.contains('"') {
// Rows side carries area strings interleaved with row track sizes.
style.grid_areas = Some(parse_grid_areas(rows_part));
} else if !rows_part.is_empty() {
let (tracks, names) = parse_track_list_named(rows_part);
style.grid_template_rows = tracks;
style.grid_row_line_names = (!names.is_empty()).then(|| build_line_map(names));
}
if let Some(cols) = cols_part {
let (tracks, names) = parse_track_list_named(cols);
style.grid_template_columns_subgrid = is_subgrid_track_list(cols);
style.grid_template_columns = tracks;
style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names));
}
}
/// Parse the common `grid` shorthand forms. A side containing `auto-flow`
/// defines the implicit placement axis; the opposite side is the explicit
/// track list. Without `auto-flow`, this is the `grid-template` shorthand.
fn parse_grid_shorthand(style: &mut LayoutStyle, value: &str) {
let Some((rows, columns)) = value.split_once('/') else {
parse_grid_template(style, value);
return;
};
let rows = rows.trim();
let columns = columns.trim();
if rows.to_ascii_lowercase().contains("auto-flow") {
style.grid_template_rows.clear();
let (tracks, names) = parse_track_list_named(columns);
style.grid_template_columns_subgrid = is_subgrid_track_list(columns);
style.grid_template_columns = tracks;
style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names));
style.grid_auto_flow = Some(if rows.to_ascii_lowercase().contains("dense") {
taffy::GridAutoFlow::RowDense
} else {
taffy::GridAutoFlow::Row
});
} else if columns.to_ascii_lowercase().contains("auto-flow") {
style.grid_template_columns.clear();
style.grid_template_columns_subgrid = false;
let (tracks, names) = parse_track_list_named(rows);
style.grid_template_rows = tracks;
style.grid_row_line_names = (!names.is_empty()).then(|| build_line_map(names));
style.grid_auto_flow = Some(if columns.to_ascii_lowercase().contains("dense") {
taffy::GridAutoFlow::ColumnDense
} else {
taffy::GridAutoFlow::Column
});
} else {
parse_grid_template(style, value);
}
}
fn is_subgrid_track_list(value: &str) -> bool {
tokenize_tracks(value)
.first()
.map(|token| token.eq_ignore_ascii_case("subgrid"))
.unwrap_or(false)
}
fn parse_grid_auto_flow(value: &str) -> Option<taffy::GridAutoFlow> {
let lower = value.trim().to_ascii_lowercase();
let dense = lower.split_whitespace().any(|token| token == "dense");
let column = lower.split_whitespace().any(|token| token == "column");
Some(match (column, dense) {
(false, false) => taffy::GridAutoFlow::Row,
(false, true) => taffy::GridAutoFlow::RowDense,
(true, false) => taffy::GridAutoFlow::Column,
(true, true) => taffy::GridAutoFlow::ColumnDense,
})
}
/// Expand the `grid-area` shorthand into its row/column placements.
///
/// The four slash-separated components are, in order, row-start,
/// column-start, row-end, and column-end. Missing end values repeat a
/// custom-ident start (the named-area form) and otherwise become `auto`, per
/// CSS Grid. Keeping the expansion in the computed style is important for the
/// common overlay idiom `grid-area: 1 / 1 / 1 / 1`; treating every slash form
/// as unsupported makes each child auto-place into a new implicit row.
fn set_grid_area(style: &mut LayoutStyle, value: &str) {
if matches!(
value.trim().to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.grid_area_name = None;
set_grid_placement(style, "auto / auto", true);
set_grid_placement(style, "auto / auto", false);
return;
}
let parts = split_top_level(value, '/')
.into_iter()
.map(str::trim)
.collect::<Vec<_>>();
if parts.is_empty()
|| parts.len() > 4
|| parts.iter().any(|part| part.is_empty())
|| parts
.iter()
.any(|part| parse_grid_line_kind(part).is_none())
{
return;
}
let row_start = parts[0];
let column_start = parts
.get(1)
.copied()
.unwrap_or_else(|| grid_area_omitted_side(row_start));
let row_end = parts
.get(2)
.copied()
.unwrap_or_else(|| grid_area_omitted_side(row_start));
let column_end = parts
.get(3)
.copied()
.unwrap_or_else(|| grid_area_omitted_side(column_start));
// Preserve the compact named-area representation used by
// `resolve_grid_areas`, while also retaining the generated `name-start` /
// `name-end` line form for grids that expose those named lines directly.
if parts.len() == 1 && is_grid_custom_ident(row_start) {
style.grid_area_name = Some(row_start.to_string());
} else {
style.grid_area_name = None;
}
set_grid_placement(
style,
&format!("{column_start} / {column_end}"),
true,
);
set_grid_placement(style, &format!("{row_start} / {row_end}"), false);
}
fn grid_area_omitted_side(start: &str) -> &str {
if is_grid_custom_ident(start) {
start
} else {
"auto"
}
}
fn is_grid_custom_ident(value: &str) -> bool {
matches!(
parse_grid_line_kind(value),
Some(GridLineKind::IdentOnly)
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GridLineKind {
IdentOnly,
Other,
}
/// Validate one `<grid-line>` without mutating the computed style. Shorthand
/// declarations are atomic: if any of their one-to-four components is invalid,
/// the entire declaration must be ignored rather than partially resetting
/// earlier winning longhands.
fn parse_grid_line_kind(value: &str) -> Option<GridLineKind> {
let mut input = cssparser::ParserInput::new(value.trim());
let mut parser = cssparser::Parser::new(&mut input);
// auto
{
let state = parser.state();
if parser
.expect_ident_cloned()
.is_ok_and(|ident| ident.eq_ignore_ascii_case("auto"))
&& parser.is_exhausted()
{
return Some(GridLineKind::Other);
}
parser.reset(&state);
}
// span && [ <positive-integer> || <custom-ident> ]
{
let state = parser.state();
if parser
.expect_ident_cloned()
.is_ok_and(|ident| ident.eq_ignore_ascii_case("span"))
{
let mut saw_integer = consume_grid_integer(&mut parser, true);
let mut saw_ident = consume_grid_custom_ident(&mut parser);
if !saw_integer {
saw_integer = consume_grid_integer(&mut parser, true);
}
if !saw_ident {
saw_ident = consume_grid_custom_ident(&mut parser);
}
if (saw_integer || saw_ident) && parser.is_exhausted() {
return Some(GridLineKind::Other);
}
}
parser.reset(&state);
}
// [ <non-zero-integer> && <custom-ident>? ], in either order.
{
let state = parser.state();
if consume_grid_integer(&mut parser, false) {
let _ = consume_grid_custom_ident(&mut parser);
if parser.is_exhausted() {
return Some(GridLineKind::Other);
}
}
parser.reset(&state);
}
{
let state = parser.state();
if consume_grid_custom_ident(&mut parser) {
if parser.is_exhausted() {
return Some(GridLineKind::IdentOnly);
}
if consume_grid_integer(&mut parser, false) && parser.is_exhausted() {
return Some(GridLineKind::Other);
}
}
parser.reset(&state);
}
// Modern engines accept an integer-valued math function as the numeric
// half of a grid line. Keep it non-ident-only so omitted shorthand sides
// become auto. Resolution remains deferred in the raw placement path.
if consume_grid_integer_math(&mut parser) {
let _ = consume_grid_custom_ident(&mut parser);
if parser.is_exhausted() {
return Some(GridLineKind::Other);
}
}
None
}
fn consume_grid_integer(parser: &mut cssparser::Parser<'_, '_>, positive_only: bool) -> bool {
let state = parser.state();
let valid = parser.expect_integer().is_ok_and(|integer| {
if positive_only {
integer > 0
} else {
integer != 0
}
});
if !valid {
parser.reset(&state);
}
valid
}
fn consume_grid_custom_ident(parser: &mut cssparser::Parser<'_, '_>) -> bool {
let state = parser.state();
let valid = parser.expect_ident_cloned().is_ok_and(|ident| {
!matches!(
ident.to_ascii_lowercase().as_str(),
"auto" | "span" | "initial" | "inherit" | "unset" | "revert" | "revert-layer"
)
});
if !valid {
parser.reset(&state);
}
valid
}
fn consume_grid_integer_math(parser: &mut cssparser::Parser<'_, '_>) -> bool {
let state = parser.state();
let is_math = parser.expect_function().is_ok_and(|name| {
matches!(
name.to_ascii_lowercase().as_str(),
"calc" | "min" | "max" | "clamp"
)
});
if !is_math {
parser.reset(&state);
return false;
}
let valid = parser
.parse_nested_block::<_, _, ()>(|nested| {
if nested.is_exhausted() {
return Err(nested.new_custom_error(()));
}
while nested.next_including_whitespace().is_ok() {}
Ok(())
})
.is_ok();
if !valid {
parser.reset(&state);
}
valid
}
/// Store a `grid-column`/`grid-row` value. Numeric/`span` forms resolve to a
/// `taffy::Line` now; a value that names a grid line (`content-start /
/// content-end`, or the `grid-column: content` area shorthand) is kept raw and
/// resolved against the parent's line-name map in `dom::resolve_grid_areas`.
/// Whichever representation is set, the other is cleared so a later cascade
/// rule of the opposite kind fully overrides it.
fn set_grid_placement(style: &mut LayoutStyle, value: &str, is_col: bool) {
if grid_line_has_name(value) {
let raw = Some(value.trim().to_string());
if is_col {
style.grid_column_raw = raw;
style.grid_column = None;
} else {
style.grid_row_raw = raw;
style.grid_row = None;
}
} else {
let line = parse_grid_line(value);
if is_col {
style.grid_column = line;
style.grid_column_raw = None;
} else {
style.grid_row = line;
style.grid_row_raw = None;
}
}
}
/// Apply one grid-placement longhand without clearing the opposite side.
/// Responsive grid systems commonly establish a default span with
/// `.layout > * { grid-column-end: span 4 }` and override only the start/end
/// on selected children; dropping these longhands traps every item in one
/// auto-placed track.
fn set_grid_placement_side(
style: &mut LayoutStyle,
value: &str,
is_col: bool,
is_start: bool,
) {
if grid_line_has_name(value) {
let raw_slot = if is_col {
&mut style.grid_column_raw
} else {
&mut style.grid_row_raw
};
let (mut start, mut end) = match raw_slot.as_deref() {
Some(raw) if raw.contains('/') => {
let (start, end) = raw.split_once('/').unwrap();
(start.trim().to_string(), end.trim().to_string())
}
// A single named grid-area shorthand contributes its `-start`
// and `-end` lines. Preserve both logical sides when a later
// longhand overrides only one of them (`grid-column:content`
// followed by `grid-column-end:extended-full-end`, as on MDN).
// Resolution can still fall back to an exact named line when no
// area-generated name exists.
Some(raw) => (raw.trim().to_string(), raw.trim().to_string()),
None => ("auto".to_string(), "auto".to_string()),
};
if is_start {
start = value.trim().to_string();
} else {
end = value.trim().to_string();
}
*raw_slot = Some(format!("{start} / {end}"));
if is_col {
style.grid_column = None;
} else {
style.grid_row = None;
}
return;
}
let placement = parse_grid_placement(value);
let line_slot = if is_col {
style.grid_column_raw = None;
&mut style.grid_column
} else {
style.grid_row_raw = None;
&mut style.grid_row
};
let mut line = line_slot.clone().unwrap_or(taffy::Line {
start: taffy::GridPlacement::Auto,
end: taffy::GridPlacement::Auto,
});
if is_start {
line.start = placement;
} else {
line.end = placement;
}
*line_slot = Some(line);
}
/// True when a `grid-column`/`grid-row` value references a named line (any
/// alphabetic token that is not a bare `span <n>` count), so it must defer to
/// the parent's line-name map.
fn grid_line_has_name(value: &str) -> bool {
let value = value.trim();
// `auto` is a grid-placement keyword, not a custom-ident. Sending
// `auto / span 4` down the named-line path loses the span whenever the
// parent has no named-line map. CSS-wide keywords likewise cannot name a
// line and must retain their reset-to-auto behavior in this compact
// computed-style model.
if value.eq_ignore_ascii_case("auto")
|| matches!(
value.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
)
{
return false;
}
value.split('/').any(|part| {
let p = part.trim();
if p.eq_ignore_ascii_case("auto") {
return false;
}
let lower = p.to_ascii_lowercase();
let rest = lower.strip_prefix("span").map(str::trim).unwrap_or(&lower);
rest.chars().any(|c| c.is_ascii_alphabetic())
})
}
/// Parse `grid-column`/`grid-row` values: `2`, `1 / 3`, `span 2`.
fn parse_grid_line(value: &str) -> Option<taffy::Line<taffy::GridPlacement>> {
let (a, b) = match value.split_once('/') {
Some((a, b)) => (a, Some(b)),
None => (value, None),
};
let start = parse_grid_placement(a);
let end = b
.map(parse_grid_placement)
.unwrap_or(taffy::GridPlacement::Auto);
Some(taffy::Line { start, end })
}
fn parse_grid_placement(value: &str) -> taffy::GridPlacement {
let value = value.trim();
let lower = value.to_ascii_lowercase();
if let Some(span) = lower.strip_prefix("span").map(str::trim) {
if let Ok(span) = span.parse::<u16>() {
return taffy::style_helpers::span(span);
}
}
if let Ok(line) = value.parse::<i16>() {
return taffy::style_helpers::line(line);
}
taffy::GridPlacement::Auto
}
/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex,
/// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named
/// colors. Returns None for anything else (transparent).
pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
parse_color_for_scheme(value, false)
}
fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> {
let raw = value.trim();
// CSS Color 5 `light-dark(light, dark)` selects by the used color scheme.
// Obscura currently exposes the default/light scheme, so return the first
// branch. Both branches still have to be complete valid colors: accepting
// a valid light arm beside malformed dark syntax would keep a declaration
// that Chromium rejects at parse time.
let lower_full = raw.to_ascii_lowercase();
if lower_full.starts_with("light-dark(") {
let inner_and_close = &raw["light-dark(".len()..];
let close = find_matching_paren(inner_and_close)?;
if !inner_and_close[close + 1..].trim().is_empty() {
return None;
}
let arguments = split_top_level(&inner_and_close[..close], ',');
if arguments.len() != 2
|| arguments
.iter()
.any(|argument| argument.trim().is_empty() || !is_complete_color_token(argument))
{
return None;
}
let light =
parse_color_for_scheme(arguments[0].trim(), dark_scheme)?;
let dark =
parse_color_for_scheme(arguments[1].trim(), dark_scheme)?;
return Some(if dark_scheme { dark } else { light });
}
// CSS custom property with a fallback: var(--name, <fallback>). We cannot
// resolve the variable, but the fallback after the comma is a real color.
if let Some(rest) = raw.strip_prefix("var(") {
let inner = rest.strip_suffix(')').unwrap_or(rest);
if let Some((_, fallback)) = inner.split_once(',') {
return parse_color_for_scheme(fallback.trim(), dark_scheme);
}
return None;
}
// rgb()/rgba() functional notation.
if let Some(rest) = lower_full
.strip_prefix("rgb(")
.or_else(|| lower_full.strip_prefix("rgba("))
{
let inner = rest.strip_suffix(')').unwrap_or(rest);
let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect();
if parts.len() >= 3 {
let c = |s: &str| -> Option<u8> {
let s = s.trim();
if let Some(pct) = s.strip_suffix('%') {
pct.parse::<f32>().ok().map(|v| (v * 2.55).round().clamp(0.0, 255.0) as u8)
} else {
s.parse::<f32>().ok().map(|v| v.round().clamp(0.0, 255.0) as u8)
}
};
let r = c(parts[0])?;
let g = c(parts[1])?;
let b = c(parts[2])?;
let a = parts.get(3)
.and_then(|s| s.trim().parse::<f32>().ok())
.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8)
.unwrap_or(255);
return Some([r, g, b, a]);
}
return None;
}
// hsl()/hsla() functional notation.
if let Some(rest) = lower_full.strip_prefix("hsl(").or_else(|| lower_full.strip_prefix("hsla(")) {
let inner = rest.strip_suffix(')').unwrap_or(rest);
let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect();
if parts.len() >= 3 {
let h = parts[0].trim().trim_end_matches("deg").parse::<f32>().ok()?;
let s = parts[1].trim().trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let l = parts[2].trim().trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let a = parts.get(3)
.and_then(|s| s.trim().trim_end_matches('%').parse::<f32>().ok())
.map(|v| if parts[3].contains('%') { (v * 2.55).round() } else { (v * 255.0).round() }.clamp(0.0, 255.0) as u8)
.unwrap_or(255);
return Some(hsl_to_rgba(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0), a));
}
return None;
}
// oklch()/oklab() - Tailwind v4's entire palette. Convert through OKLab to
// sRGB; without this every modern-framework color resolved to nothing.
if lower_full.starts_with("oklch(") || lower_full.starts_with("oklab(") {
let is_lch = lower_full.starts_with("oklch(");
let inner = &lower_full[if is_lch { 6 } else { 6 }..];
let inner = inner.strip_suffix(')').unwrap_or(inner);
let (main, alpha) = match inner.split_once('/') {
Some((m, a)) => (m, Some(a)),
None => (inner, None),
};
let comps: Vec<&str> = main.split([',', ' ']).filter(|p| !p.trim().is_empty()).collect();
if comps.len() >= 3 {
let num = |s: &str| -> Option<f32> {
let s = s.trim();
s.strip_suffix('%').map(|p| p.parse::<f32>().map(|v| v / 100.0)).unwrap_or_else(|| s.parse::<f32>())
.ok()
};
let l = num(comps[0])?;
let c = num(comps[1])?;
let a = alpha
.and_then(|s| {
let s = s.trim();
s.strip_suffix('%').map(|p| p.parse::<f32>().map(|v| v / 100.0)).unwrap_or_else(|| s.parse::<f32>()).ok()
})
.unwrap_or(1.0);
let (oa, ob) = if is_lch {
let h = comps[2].trim().trim_end_matches("deg").parse::<f32>().ok()?;
let hr = h.to_radians();
(c * hr.cos(), c * hr.sin())
} else {
(c, comps[2].trim().parse::<f32>().ok()?)
};
return Some(oklab_to_rgba(l, oa, ob, (a * 255.0).round().clamp(0.0, 255.0) as u8));
}
return None;
}
// color-mix(in <space>, c1 p1%, c2 p2%) - Tailwind v4 uses this pervasively,
// usually `color-mix(in oklab, <color> N%, transparent)` to apply opacity.
if lower_full.starts_with("color-mix(") {
let inner = raw[raw.to_ascii_lowercase().find("color-mix(").unwrap() + "color-mix(".len()..].trim_end();
let inner = inner.strip_suffix(')').unwrap_or(inner);
let args = split_top_commas(inner);
if args.len() >= 3 {
let parse_arg = |s: &str| -> Option<([u8; 4], Option<f32>)> {
let s = s.trim();
if let Some(idx) = s.rfind(char::is_whitespace) {
let tail = s[idx + 1..].trim();
if let Some(p) = tail.strip_suffix('%').and_then(|x| x.parse::<f32>().ok()) {
return parse_color_for_scheme(s[..idx].trim(), dark_scheme)
.map(|c| (c, Some(p / 100.0)));
}
}
parse_color_for_scheme(s, dark_scheme).map(|c| (c, None))
};
if let (Some((c1, p1)), Some((c2, p2))) = (parse_arg(args[1]), parse_arg(args[2])) {
let (w1, w2) = match (p1, p2) {
(Some(a), Some(b)) => (a, b),
(Some(a), None) => (a, 1.0 - a),
(None, Some(b)) => (1.0 - b, b),
(None, None) => (0.5, 0.5),
};
let tot = (w1 + w2).max(1e-6);
let (w1, w2) = (w1 / tot, w2 / tot);
// Mixing with a fully transparent color is the opacity idiom:
// keep the visible color, scale its alpha (not toward black).
if c2[3] == 0 {
return Some([c1[0], c1[1], c1[2], (c1[3] as f32 * w1).round().clamp(0.0, 255.0) as u8]);
}
if c1[3] == 0 {
return Some([c2[0], c2[1], c2[2], (c2[3] as f32 * w2).round().clamp(0.0, 255.0) as u8]);
}
let m = |i: usize| (c1[i] as f32 * w1 + c2[i] as f32 * w2).round().clamp(0.0, 255.0) as u8;
return Some([m(0), m(1), m(2), m(3)]);
}
}
return None;
}
let v = value.split_whitespace().next()?.to_ascii_lowercase();
if let Some(h) = v.strip_prefix('#') {
let (r, g, b, a) = match h.len() {
3 => (
u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?,
u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?,
u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?,
255u8,
),
4 => (
u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?,
u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?,
u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?,
u8::from_str_radix(&h[3..4].repeat(2), 16).ok()?,
),
6 => (
u8::from_str_radix(&h[0..2], 16).ok()?,
u8::from_str_radix(&h[2..4], 16).ok()?,
u8::from_str_radix(&h[4..6], 16).ok()?,
255u8,
),
8 => (
u8::from_str_radix(&h[0..2], 16).ok()?,
u8::from_str_radix(&h[2..4], 16).ok()?,
u8::from_str_radix(&h[4..6], 16).ok()?,
u8::from_str_radix(&h[6..8], 16).ok()?,
),
_ => return None,
};
return Some([r, g, b, a]);
}
match v.as_str() {
"white" => Some([255, 255, 255, 255]),
"black" => Some([0, 0, 0, 255]),
"gray" | "grey" => Some([128, 128, 128, 255]),
"silver" => Some([192, 192, 192, 255]),
"lightgray" | "lightgrey" => Some([211, 211, 211, 255]),
"darkgray" | "darkgrey" => Some([169, 169, 169, 255]),
"whitesmoke" => Some([245, 245, 245, 255]),
"gainsboro" => Some([220, 220, 220, 255]),
"red" => Some([255, 0, 0, 255]),
"green" => Some([0, 128, 0, 255]),
"lime" => Some([0, 255, 0, 255]),
"blue" => Some([0, 0, 255, 255]),
"navy" => Some([0, 0, 128, 255]),
"yellow" => Some([255, 255, 0, 255]),
"orange" => Some([255, 165, 0, 255]),
"purple" => Some([128, 0, 128, 255]),
"maroon" => Some([128, 0, 0, 255]),
"teal" => Some([0, 128, 128, 255]),
"aqua" | "cyan" => Some([0, 255, 255, 255]),
"fuchsia" | "magenta" => Some([255, 0, 255, 255]),
"olive" => Some([128, 128, 0, 255]),
"transparent" => Some([0, 0, 0, 0]),
_ => named_color(&v),
}
}
fn resolve_svg_presentation_color(value: &str, dark_scheme: bool) -> String {
let raw = value.trim();
if raw.to_ascii_lowercase().contains("light-dark(") {
if let Some([red, green, blue, alpha]) =
parse_color_for_scheme(raw, dark_scheme)
{
return format!("#{red:02x}{green:02x}{blue:02x}{alpha:02x}");
}
}
raw.to_string()
}
/// The remaining common CSS named colors (the hot ones from real sites) beyond
/// the handful spelled out above.
fn named_color(v: &str) -> Option<[u8; 4]> {
let rgb = match v {
"darkblue" => [0, 0, 139],
"mediumblue" => [0, 0, 205],
"royalblue" => [65, 105, 225],
"dodgerblue" => [30, 144, 255],
"cornflowerblue" => [100, 149, 237],
"steelblue" => [70, 130, 180],
"deepskyblue" => [0, 191, 255],
"skyblue" => [135, 206, 235],
"lightskyblue" => [135, 206, 250],
"lightblue" => [173, 216, 230],
"powderblue" => [176, 224, 230],
"cadetblue" => [95, 158, 160],
"slateblue" => [106, 90, 205],
"darkslateblue" => [72, 61, 139],
"midnightblue" => [25, 25, 112],
"indigo" => [75, 0, 130],
"darkgreen" => [0, 100, 0],
"forestgreen" => [34, 139, 34],
"seagreen" => [46, 139, 87],
"mediumseagreen" => [60, 179, 113],
"limegreen" => [50, 205, 50],
"yellowgreen" => [154, 205, 50],
"olivedrab" => [107, 142, 35],
"darkolivegreen" => [85, 107, 47],
"greenyellow" => [173, 255, 47],
"lightgreen" => [144, 238, 144],
"palegreen" => [152, 251, 152],
"springgreen" => [0, 255, 127],
"mediumaquamarine" => [102, 205, 170],
"aquamarine" => [127, 255, 212],
"turquoise" => [64, 224, 208],
"mediumturquoise" => [72, 209, 204],
"darkcyan" => [0, 139, 139],
"crimson" => [220, 20, 60],
"firebrick" => [178, 34, 34],
"darkred" => [139, 0, 0],
"indianred" => [205, 92, 92],
"tomato" => [255, 99, 71],
"orangered" => [255, 69, 0],
"coral" => [255, 127, 80],
"salmon" => [250, 128, 114],
"lightsalmon" => [255, 160, 122],
"darksalmon" => [233, 150, 122],
"hotpink" => [255, 105, 180],
"deeppink" => [255, 20, 147],
"pink" => [255, 192, 203],
"lightpink" => [255, 182, 193],
"palevioletred" => [219, 112, 147],
"mediumvioletred" => [199, 21, 133],
"violet" => [238, 130, 238],
"orchid" => [218, 112, 214],
"plum" => [221, 160, 221],
"mediumpurple" => [147, 112, 219],
"blueviolet" => [138, 43, 226],
"darkviolet" => [148, 0, 211],
"darkorchid" => [153, 50, 204],
"darkmagenta" => [139, 0, 139],
"lavender" => [230, 230, 250],
"thistle" => [216, 191, 216],
"gold" => [255, 215, 0],
"goldenrod" => [218, 165, 32],
"darkgoldenrod" => [184, 134, 11],
"khaki" => [240, 230, 140],
"darkkhaki" => [189, 183, 107],
"peachpuff" => [255, 218, 185],
"moccasin" => [255, 228, 181],
"papayawhip" => [255, 239, 213],
"wheat" => [245, 222, 179],
"tan" => [210, 180, 140],
"burlywood" => [222, 184, 135],
"sandybrown" => [244, 164, 96],
"peru" => [205, 133, 63],
"chocolate" => [210, 105, 30],
"sienna" => [160, 82, 45],
"saddlebrown" => [139, 69, 19],
"brown" => [165, 42, 42],
"rosybrown" => [188, 143, 143],
"darkorange" => [255, 140, 0],
"lightyellow" => [255, 255, 224],
"lightgoldenrodyellow" => [250, 250, 210],
"lemonchiffon" => [255, 250, 205],
"beige" => [245, 245, 220],
"ivory" => [255, 255, 240],
"azure" => [240, 255, 255],
"mintcream" => [245, 255, 250],
"honeydew" => [240, 255, 240],
"snow" => [255, 250, 250],
"seashell" => [255, 245, 238],
"linen" => [250, 240, 230],
"oldlace" => [253, 245, 230],
"floralwhite" => [255, 250, 240],
"ghostwhite" => [248, 248, 255],
"aliceblue" => [240, 248, 255],
"lavenderblush" => [255, 240, 245],
"mistyrose" => [255, 228, 225],
"cornsilk" => [255, 248, 220],
"antiquewhite" => [250, 235, 215],
"bisque" => [255, 228, 196],
"blanchedalmond" => [255, 235, 205],
"navajowhite" => [255, 222, 173],
"dimgray" | "dimgrey" => [105, 105, 105],
"slategray" | "slategrey" => [112, 128, 144],
"lightslategray" | "lightslategrey" => [119, 136, 153],
"darkslategray" | "darkslategrey" => [47, 79, 79],
_ => return None,
};
Some([rgb[0], rgb[1], rgb[2], 255])
}
/// Reject trailing tokens and unbalanced functions before a `light-dark()`
/// branch reaches the intentionally permissive legacy color parser. Nested
/// color functions and their internal whitespace remain valid.
fn is_complete_color_token(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
let mut depth = 0i32;
let mut first_open = None;
let mut outer_close = None;
for (index, character) in value.char_indices() {
match character {
'(' => {
if depth == 0 && first_open.is_none() {
first_open = Some(index);
}
depth += 1;
}
')' => {
depth -= 1;
if depth < 0 {
return false;
}
if depth == 0 {
outer_close = Some(index);
}
}
_ => {}
}
}
if depth != 0 {
return false;
}
match first_open {
Some(open) => {
open > 0
&& value[..open]
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
&& outer_close == Some(value.len() - 1)
}
None => !value.chars().any(char::is_whitespace),
}
}
/// Convert `hsl()`/`hsla()` to RGBA. `h` in degrees, `s`/`l` as 0-1.
/// Convert an OKLab color (L in 0..1, a/b unbounded) to sRGB rgba bytes.
/// (oklch is converted to oklab by the caller.) Standard Björn Ottosson matrix.
fn oklab_to_rgba(l: f32, a: f32, b: f32, alpha: u8) -> [u8; 4] {
let l_ = l + 0.3963377774 * a + 0.2158037573 * b;
let m_ = l - 0.1055613458 * a - 0.0638541728 * b;
let s_ = l - 0.0894841775 * a - 1.2914855480 * b;
let (lc, mc, sc) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
let lr = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
let lg = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
let lb = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
let enc = |x: f32| {
let x = x.clamp(0.0, 1.0);
let s = if x <= 0.0031308 { 12.92 * x } else { 1.055 * x.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round().clamp(0.0, 255.0) as u8
};
[enc(lr), enc(lg), enc(lb), alpha]
}
/// Split on top-level commas, respecting nested `()` (so a `color-mix` argument
/// like `oklch(0.7 0.1 20)` or `var(--x, y)` is not shattered).
fn split_top_commas(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut start = 0;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => depth = (depth - 1).max(0),
',' if depth == 0 => {
out.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&s[start..]);
out
}
fn hsl_to_rgba(h: f32, s: f32, l: f32, a: u8) -> [u8; 4] {
let h = ((h % 360.0) + 360.0) % 360.0;
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
let m = l - c / 2.0;
let (r, g, b) = match h as u32 / 60 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
[
(((r + m) * 255.0).round().clamp(0.0, 255.0)) as u8,
(((g + m) * 255.0).round().clamp(0.0, 255.0)) as u8,
(((b + m) * 255.0).round().clamp(0.0, 255.0)) as u8,
a,
]
}
enum Side { Top, Right, Bottom, Left }
fn border_side_width(value: &str) -> Option<f32> {
if value.split_whitespace().any(|token| {
token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden")
}) {
Some(0.0)
} else {
px(value)
}
}
fn set_edge(edges: &mut Edges, side: Side, v: Option<f32>) {
let v = match v { Some(v) => v, None => return };
match side {
Side::Top => edges.top = v,
Side::Right => edges.right = v,
Side::Bottom => edges.bottom = v,
Side::Left => edges.left = v,
}
}
/// Parse the first length in a value as CSS pixels. `auto` and non-numeric
/// values return None (treated as "no explicit size" in phase 1). Delegates
/// to `resolve_length` for anything beyond a bare token, since `calc()`,
/// `var()`, and `min()`/`max()` all contain spaces that would otherwise break
/// the single-token fast path.
fn px(value: &str) -> Option<f32> {
let trimmed = value.trim();
if trimmed.contains('(') {
return resolve_length(trimmed);
}
token(value).and_then(px_value)
}
fn deferred_length_expression(value: &str) -> Option<String> {
value.trim().contains('(').then(|| value.trim().to_string())
}
/// Resolve functional CSS lengths once the actual layout context is known.
/// `vw`/`vh` are one-percent viewport units and `percent_base` is the relevant
/// containing-block dimension. Custom properties have already been substituted
/// by the cascade before this function sees the expression.
pub(crate) fn resolve_contextual_length(
value: &str,
em_px: f32,
rem_px: f32,
vw: f32,
vh: f32,
percent_base: f32,
) -> Option<f32> {
let context = LengthContext {
em_px,
rem_px,
vw,
vh,
percent_base,
};
resolve_contextual(value, &context)
}
#[derive(Clone, Copy)]
struct LengthContext {
em_px: f32,
rem_px: f32,
vw: f32,
vh: f32,
percent_base: f32,
}
fn resolve_contextual(value: &str, context: &LengthContext) -> Option<f32> {
let value = value.trim();
if let Some(rest) = value.strip_prefix('(') {
let end = find_matching_paren(rest)?;
if end + 2 == value.len() {
return eval_contextual_calc(&rest[..end], context);
}
}
if let Some(rest) = value.strip_prefix("var(") {
let end = find_matching_paren(rest)?;
let inner = &rest[..end];
let (_, fallback) = inner.split_once(',')?;
return resolve_contextual(fallback.trim(), context);
}
if let Some(rest) = value.strip_prefix("calc(") {
let end = find_matching_paren(rest)?;
return eval_contextual_calc(&rest[..end], context);
}
if let Some(rest) = value
.strip_prefix("max(")
.or_else(|| value.strip_prefix("min("))
{
let is_max = value.starts_with("max(");
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
let mut values = args
.iter()
.filter_map(|arg| eval_contextual_calc(arg, context));
let mut best = values.next()?;
for candidate in values {
if (is_max && candidate > best) || (!is_max && candidate < best) {
best = candidate;
}
}
return Some(best);
}
if let Some(rest) = value.strip_prefix("clamp(") {
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
if args.len() == 3 {
let low = eval_contextual_calc(args[0], context)?;
let preferred = eval_contextual_calc(args[1], context)?;
let high = eval_contextual_calc(args[2], context)?;
return Some(preferred.min(high).max(low));
}
}
if let Some(rest) = value.strip_prefix("round(") {
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
let (strategy, value_index) = match args.first()?.trim() {
"nearest" | "up" | "down" | "to-zero" => (args[0].trim(), 1),
_ => ("nearest", 0),
};
let resolved = eval_contextual_calc(args.get(value_index)?.trim(), context)?;
let step = match args.get(value_index + 1) {
Some(step) => eval_contextual_calc(step.trim(), context)?,
None => 1.0,
};
return round_css_value(resolved, step, strategy);
}
contextual_atom(value, context)
}
fn round_css_value(value: f32, step: f32, strategy: &str) -> Option<f32> {
if !value.is_finite() || !step.is_finite() || step == 0.0 {
return None;
}
let quotient = value / step.abs();
let rounded = match strategy {
"up" => quotient.ceil(),
"down" => quotient.floor(),
"to-zero" => quotient.trunc(),
_ => quotient.round(),
};
Some(rounded * step.abs())
}
fn contextual_atom(value: &str, context: &LengthContext) -> Option<f32> {
let lower = value.trim().to_ascii_lowercase();
let parse = |number: &str| number.trim().parse::<f32>().ok();
if let Some(value) = lower.strip_suffix("rem").and_then(parse) {
return Some(value * context.rem_px);
}
if let Some(value) = lower.strip_suffix("em").and_then(parse) {
return Some(value * context.em_px);
}
if let Some(value) = lower.strip_suffix("ex").and_then(parse) {
return Some(value * context.em_px * 0.528_320_3);
}
if let Some(value) = lower.strip_suffix("vmin").and_then(parse) {
return Some(value * context.vw.min(context.vh));
}
if let Some(value) = lower.strip_suffix("vmax").and_then(parse) {
return Some(value * context.vw.max(context.vh));
}
if let Some(value) = lower
.strip_suffix("dvw")
.or_else(|| lower.strip_suffix("svw"))
.or_else(|| lower.strip_suffix("lvw"))
.and_then(parse)
{
return Some(value * context.vw);
}
if let Some(value) = lower
.strip_suffix("dvh")
.or_else(|| lower.strip_suffix("svh"))
.or_else(|| lower.strip_suffix("lvh"))
.and_then(parse)
{
return Some(value * context.vh);
}
if let Some(value) = lower.strip_suffix("vw").and_then(parse) {
return Some(value * context.vw);
}
if let Some(value) = lower.strip_suffix("vh").and_then(parse) {
return Some(value * context.vh);
}
if let Some(value) = lower.strip_suffix('%').and_then(parse) {
return Some(value * context.percent_base / 100.0);
}
if let Some(value) = lower.strip_suffix("px").and_then(parse) {
return Some(value);
}
if let Some(value) = lower.strip_suffix("pt").and_then(parse) {
return Some(value * 1.333);
}
parse(&lower)
}
fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option<f32> {
let mut terms: Vec<(f32, String)> = Vec::new();
let mut sign = 1.0;
let mut current = String::new();
let mut depth = 0i32;
for character in expr.chars() {
match character {
'(' => {
depth += 1;
current.push(character);
}
')' => {
depth -= 1;
current.push(character);
}
'+' | '-' if depth == 0 => {
let follows_product_operator = current
.chars()
.rev()
.find(|character| !character.is_whitespace())
.is_some_and(|character| matches!(character, '*' | '/'));
if follows_product_operator {
current.push(character);
} else {
if !current.trim().is_empty() {
terms.push((sign, std::mem::take(&mut current)));
}
sign = if character == '-' { -1.0 } else { 1.0 };
}
}
_ => current.push(character),
}
}
if !current.trim().is_empty() {
terms.push((sign, current));
}
if terms.is_empty() {
return None;
}
let mut total = 0.0;
for (term_sign, term) in terms {
total += term_sign * eval_contextual_product(term.trim(), context)?;
}
Some(total)
}
fn eval_contextual_product(term: &str, context: &LengthContext) -> Option<f32> {
let mut result: Option<f32> = None;
let mut operator = '*';
let mut depth = 0i32;
let mut current = String::new();
let mut factors: Vec<(char, String)> = Vec::new();
for character in term.chars() {
match character {
'(' => {
depth += 1;
current.push(character);
}
')' => {
depth -= 1;
current.push(character);
}
'*' | '/' if depth == 0 => {
if current.trim().is_empty() {
return None;
}
factors.push((operator, std::mem::take(&mut current)));
operator = character;
}
_ => current.push(character),
}
}
if current.trim().is_empty() {
return None;
}
factors.push((operator, current));
for (operator, factor) in &factors {
let value = resolve_contextual(factor, context)?;
result = Some(match result {
None => value,
Some(previous) if *operator == '/' => previous / value,
Some(previous) => previous * value,
});
}
result
}
/// Resolve a CSS length expression to px, recursively handling the small set
/// of functional forms real stylesheets actually nest in practice:
/// `var(--x, fallback)` (substitute the fallback; we track no custom
/// property values), `calc(...)`, and `min()`/`max()`. These commonly nest
/// inside each other (`calc(max(calc(var(--x,1rem) + 4px),10px))` is a real
/// example from Wikipedia's icon sizing), so each case recurses back into
/// this function rather than assuming a flat expression.
fn resolve_length(value: &str) -> Option<f32> {
let v = value.trim();
if let Some(rest) = v.strip_prefix('(') {
let end = find_matching_paren(rest)?;
if end + 2 == v.len() {
return eval_calc(&rest[..end]);
}
}
if let Some(rest) = v.strip_prefix("var(") {
let end = find_matching_paren(rest)?;
let inner = &rest[..end];
let (_, fallback) = inner.split_once(',')?;
return resolve_length(fallback.trim());
}
if let Some(rest) = v.strip_prefix("calc(") {
let end = find_matching_paren(rest)?;
return eval_calc(&rest[..end]);
}
if let Some(rest) = v.strip_prefix("max(").or_else(|| v.strip_prefix("min(")) {
let is_max = v.starts_with("max(");
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
let mut values = args.iter().filter_map(|a| eval_calc(a));
let mut best = values.next()?;
for val in values {
if (is_max && val > best) || (!is_max && val < best) {
best = val;
}
}
return Some(best);
}
if let Some(rest) = v.strip_prefix("clamp(") {
// clamp(min, preferred, max) == max(min, min(preferred, max)). Widely
// used for responsive widths/font-sizes/gaps; returning None here made
// any `width: clamp(...)` element collapse (svelte.dev's hero grid).
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
if args.len() == 3 {
let lo = eval_calc(args[0].trim())?;
let mid = eval_calc(args[1].trim())?;
let hi = eval_calc(args[2].trim())?;
return Some(mid.min(hi).max(lo));
}
}
if let Some(rest) = v.strip_prefix("round(") {
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
let (strategy, value_index) = match args.first()?.trim() {
"nearest" | "up" | "down" | "to-zero" => (args[0].trim(), 1),
_ => ("nearest", 0),
};
let resolved = eval_calc(args.get(value_index)?.trim())?;
let step = match args.get(value_index + 1) {
Some(step) => eval_calc(step.trim())?,
None => 1.0,
};
return round_css_value(resolved, step, strategy);
}
if v.contains('(') {
return None; // an unhandled function (env(), ...): no safe fallback
}
px_value(v).or_else(|| v.parse::<f32>().ok())
}
/// Find the index (relative to `s`) of the `)` matching an already-consumed
/// opening `(`, accounting for nesting.
fn find_matching_paren(s: &str) -> Option<usize> {
let mut depth = 1i32;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
/// Split on `sep` at paren-depth 0 only, so `max(a,b)` inside an argument
/// list is not itself split on its internal comma.
fn split_top_level(s: &str, sep: char) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut start = 0;
let mut escaped = false;
for (i, c) in s.char_indices() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
match c {
'(' => depth += 1,
')' => depth -= 1,
c if c == sep && depth == 0 => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
/// Evaluate a `calc()` body (already stripped of `calc(` / the matching `)`):
/// a left-to-right sum of terms, each itself a left-to-right `*`/`/` chain.
/// Terms may themselves be nested `max()`/`min()`/`var()`/`calc()` calls.
fn eval_calc(expr: &str) -> Option<f32> {
let mut terms: Vec<(f32, String)> = Vec::new();
let mut sign = 1.0;
let mut cur = String::new();
let mut depth = 0i32;
for c in expr.chars() {
match c {
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
'+' | '-' if depth == 0 => {
let follows_product_operator = cur
.chars()
.rev()
.find(|character| !character.is_whitespace())
.is_some_and(|character| matches!(character, '*' | '/'));
if follows_product_operator {
cur.push(c);
} else {
if !cur.trim().is_empty() {
terms.push((sign, std::mem::take(&mut cur)));
}
sign = if c == '-' { -1.0 } else { 1.0 };
}
}
_ => cur.push(c),
}
}
if !cur.trim().is_empty() {
terms.push((sign, cur));
}
if terms.is_empty() {
return None;
}
let mut total = 0.0;
for (term_sign, term) in terms {
total += term_sign * eval_product(term.trim())?;
}
Some(total)
}
/// Evaluate a `*`/`/` chain within one additive term of a calc() expression,
/// e.g. `-1 * 22px / 2`, where a factor may itself be a nested function call.
fn eval_product(term: &str) -> Option<f32> {
let mut result: Option<f32> = None;
let mut op = '*';
let mut depth = 0i32;
let mut cur = String::new();
let mut factors: Vec<(char, String)> = Vec::new();
for c in term.chars() {
match c {
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
'*' | '/' if depth == 0 => {
if cur.trim().is_empty() {
return None;
}
factors.push((op, std::mem::take(&mut cur)));
op = c;
}
_ => cur.push(c),
}
}
if cur.trim().is_empty() {
return None;
}
factors.push((op, cur));
for (op, tok) in &factors {
let v = resolve_length(tok)?;
result = Some(match result {
None => v,
Some(r) if *op == '/' => r / v,
Some(r) => r * v,
});
}
result
}
/// Recognize a `list-style-type` / `list-style` keyword. Returns `None` for
/// tokens that are not list-style types (positions like `inside`, `url(...)`,
/// or unknown type names), so a shorthand scan can skip them.
fn list_style_keyword(tok: &str) -> Option<crate::ListStyle> {
match tok.trim() {
"none" => Some(crate::ListStyle::None),
"disc" => Some(crate::ListStyle::Disc),
"circle" => Some(crate::ListStyle::Circle),
"square" => Some(crate::ListStyle::Square),
"decimal" | "decimal-leading-zero" => Some(crate::ListStyle::Decimal),
_ => None,
}
}
/// An inset component (top/right/bottom/left). `auto` and absent both become
/// `None`; everything else keeps its (possibly relative) dimension for the
/// resolution pass.
fn inset_dim(value: &str) -> Option<crate::Dimension> {
match dimension_value(value) {
crate::Dimension::Auto => None,
d => Some(d),
}
}
fn set_inset_side(style: &mut LayoutStyle, index: usize, value: &str) {
let value = value.trim();
if let Some(expression) = deferred_length_expression(value) {
style.inset[index] = None;
style.inset_expressions[index] = Some(expression);
} else {
style.inset[index] = inset_dim(value);
style.inset_expressions[index] = None;
}
}
/// Parse `counter-reset`, `counter-increment`, and `counter-set` name/value
/// pairs. Gecko likewise assigns the property-specific default integer when a
/// name is not followed by an integer (reset/set 0, increment 1).
fn parse_counter_directives(
value: &str,
default_value: i32,
) -> Option<Vec<crate::CounterDirective>> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("none")
|| matches!(
trimmed.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
)
{
return Some(Vec::new());
}
let tokens = split_ws_paren(trimmed);
if tokens.is_empty() {
return None;
}
let mut result = Vec::new();
let mut index = 0;
while index < tokens.len() {
let name = tokens[index].trim();
if !valid_counter_name(name) {
return None;
}
index += 1;
let value = tokens
.get(index)
.and_then(|token| token.parse::<i32>().ok())
.map(|value| {
index += 1;
value
})
.unwrap_or(default_value);
result.push(crate::CounterDirective {
name: name.to_string(),
value,
});
}
Some(result)
}
fn valid_counter_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
!name.is_empty()
&& !matches!(
lower.as_str(),
"none" | "initial" | "inherit" | "unset" | "revert" | "revert-layer"
)
&& !name
.contains(|ch: char| ch.is_whitespace() || matches!(ch, '(' | ')' | ',' | '"' | '\''))
}
/// Absolute keyword font-sizes (the `medium`-anchored scale), for the handful
/// of pages that still use them.
fn font_size_keyword(v: &str) -> Option<f32> {
Some(match v.to_ascii_lowercase().as_str() {
"xx-small" => 9.6,
"x-small" => 12.0,
"small" => 13.3,
"medium" => 16.0,
"large" => 18.0,
"x-large" => 24.0,
"xx-large" => 32.0,
_ => return None,
})
}
fn apply_font_size(style: &mut LayoutStyle, value: &str) {
if value.trim().contains('(') {
style.font_size = None;
style.font_size_raw = None;
style.font_size_expression = Some(value.trim().to_string());
return;
}
style.font_size_expression = None;
match dimension_value(value) {
crate::Dimension::Px(p) => {
style.font_size = Some(p);
style.font_size_raw = None;
}
crate::Dimension::Auto => {
// Keyword sizes (medium/small/large/...) or unknown; map the
// common ones, else leave to inherit.
if let Some(px) = font_size_keyword(value.trim()) {
style.font_size = Some(px);
style.font_size_raw = None;
}
}
rel => {
style.font_size = None;
style.font_size_raw = Some(rel);
}
}
}
fn apply_letter_spacing(style: &mut LayoutStyle, value: &str) {
let value = value.trim();
let lower = value.to_ascii_lowercase();
if matches!(
lower.as_str(),
"normal" | "initial" | "revert" | "revert-layer"
) {
style.letter_spacing = Some(0.0);
style.letter_spacing_raw = None;
style.letter_spacing_expression = None;
style.letter_spacing_non_normal = Some(false);
return;
}
// `letter-spacing` inherits, so `unset` has the same computed behavior as
// `inherit`. Leave this style unspecified for the top-down pass.
if matches!(lower.as_str(), "inherit" | "unset") || value.is_empty() {
return;
}
// Percentages are invalid even inside CSS math. Do not clear an earlier
// valid cascade winner when a later declaration is invalid.
if value.contains('%') {
return;
}
style.letter_spacing_non_normal = Some(true);
if value.contains('(') {
style.letter_spacing = None;
style.letter_spacing_raw = None;
style.letter_spacing_expression = Some(value.to_string());
return;
}
style.letter_spacing_expression = None;
match dimension_value(value) {
crate::Dimension::Px(pixels) if pixels.is_finite() => {
style.letter_spacing = Some(pixels);
style.letter_spacing_raw = None;
}
crate::Dimension::Percent(_) | crate::Dimension::Auto => {
// Percentages and keywords other than `normal` are invalid.
style.letter_spacing = None;
style.letter_spacing_raw = None;
style.letter_spacing_non_normal = None;
}
relative => {
style.letter_spacing = None;
style.letter_spacing_raw = Some(relative);
}
}
}
fn parse_text_indent(value: &str) -> Option<crate::Dimension> {
// The Level 3 `hanging` and `each-line` keywords change which lines are
// indented. Keep them invalid until those semantics are represented.
if split_ws_paren(value).len() != 1 {
return None;
}
let dimension = dimension_value(value);
(!matches!(dimension, crate::Dimension::Auto)).then_some(dimension)
}
fn apply_text_indent(style: &mut LayoutStyle, value: &str) {
match value.trim().to_ascii_lowercase().as_str() {
"initial" => style.text_indent = Some(crate::Dimension::Px(0.0)),
// This is an inherited property, so `unset` computes like `inherit`.
"inherit" | "unset" | "revert" | "revert-layer" => style.text_indent = None,
_ => {
if let Some(indent) = parse_text_indent(value) {
style.text_indent = Some(indent);
}
}
}
}
fn apply_gap_value(style: &mut LayoutStyle, row: bool, value: &str) {
let value = value.trim();
let lower = value.to_ascii_lowercase();
let contextual = lower.contains('(')
|| lower.ends_with('%')
|| ["rem", "em", "ex", "vw", "vh", "vmin", "vmax"]
.iter()
.any(|unit| lower.ends_with(unit));
let expression = if value.eq_ignore_ascii_case("normal")
|| value.is_empty()
|| !contextual
{
None
} else {
Some(value.to_string())
};
let immediate = if value.eq_ignore_ascii_case("normal") || value.is_empty() {
None
} else {
px(value)
};
if row {
style.row_gap = immediate;
style.row_gap_expression = expression;
} else {
style.column_gap = immediate;
style.column_gap_expression = expression;
}
}
fn parse_column_count(value: &str) -> Option<u16> {
let count = value.trim().parse::<u32>().ok()?;
// Keep pathological author input from allocating thousands of anonymous
// fragmentainers. Real page layouts use single-digit counts; 64 retains
// ample useful range while keeping construction and balancing bounded.
(count > 0).then(|| count.min(64) as u16)
}
pub(crate) fn line_height_expression_is_length(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.contains('%')
|| [
"px", "pt", "pc", "in", "cm", "mm", "rem", "em", "ex", "vw", "vh", "vmin", "vmax",
]
.iter()
.any(|unit| lower.contains(unit))
}
/// Parse the layout-relevant portion of the CSS `font` shorthand:
/// `[style || variant || weight || stretch]? size [/ line-height]? family`.
///
/// As in Gecko's shorthand expansion, omitted longhands reset to their
/// initial values instead of inheriting a previously cascaded declaration.
/// We do not model variant/stretch, but still accept their keywords before
/// the required size so modern design-system declarations reach the size,
/// line-height, weight, style, and family fields that affect our layout.
fn apply_font_shorthand(style: &mut LayoutStyle, value: &str) {
let tokens = split_ws_paren(value);
let Some((size_index, size, attached_line_height)) =
tokens.iter().enumerate().find_map(|(index, token)| {
let (candidate, line_height) = token.split_once('/').unwrap_or((token, ""));
is_font_size_token(candidate).then_some((index, candidate, line_height))
})
else {
return;
};
let mut family_index = size_index + 1;
let mut line_height = (!attached_line_height.is_empty()).then_some(attached_line_height);
if line_height.is_none() && family_index < tokens.len() {
if tokens[family_index] == "/" {
family_index += 1;
if family_index < tokens.len() {
line_height = Some(tokens[family_index]);
family_index += 1;
}
} else if let Some(after_slash) = tokens[family_index].strip_prefix('/') {
if !after_slash.is_empty() {
line_height = Some(after_slash);
}
family_index += 1;
}
}
if family_index >= tokens.len() {
return;
}
// The shorthand resets every constituent before applying supplied values.
style.font_style_italic = Some(false);
style.font_weight = Some("400".to_string());
style.font_optical_sizing = Some(crate::FontOpticalSizing::Auto);
style.font_variation_settings = Some(Vec::new());
style.line_height = Some(crate::LineHeight::Normal);
style.line_height_expression = None;
for token in &tokens[..size_index] {
let lower = token.to_ascii_lowercase();
if lower == "italic" || lower.starts_with("oblique") {
style.font_style_italic = Some(true);
} else if let Some(weight) = specified_font_weight(&lower) {
style.font_weight = Some(weight);
}
}
apply_font_size(style, size);
if let Some(line_height) = line_height {
apply_value(style, "line-height", line_height);
}
style.font_family = Some(tokens[family_index..].join(" ").to_ascii_lowercase());
}
/// Normalize a specified CSS font weight while preserving relative keywords
/// until the top-down inheritance pass can see the parent's computed weight.
fn specified_font_weight(value: &str) -> Option<String> {
let lower = value.trim().to_ascii_lowercase();
match lower.as_str() {
"normal" => Some("400".to_string()),
"bold" => Some("700".to_string()),
"bolder" | "lighter" => Some(lower),
_ => lower
.parse::<f32>()
.ok()
.filter(|weight| weight.is_finite() && (1.0..=1000.0).contains(weight))
.map(|weight| weight.round().to_string()),
}
}
/// Resolve `font-weight` to the numeric computed value defined by CSS Fonts.
/// Relative keywords use the inherited weight table rather than a binary
/// normal/bold threshold.
pub(crate) fn computed_font_weight(specified: Option<&str>, inherited: u16) -> u16 {
match specified {
None => inherited,
Some("inherit" | "unset") => inherited,
Some("normal" | "initial") => 400,
Some("bold") => 700,
Some("bolder") if inherited < 100 => 400,
Some("bolder") if inherited < 350 => 400,
Some("bolder") if inherited < 550 => 700,
Some("bolder") if inherited < 900 => 900,
Some("bolder") => inherited,
Some("lighter") if inherited < 100 => inherited,
Some("lighter") if inherited < 350 => 100,
Some("lighter") if inherited < 550 => 100,
Some("lighter") if inherited < 750 => 400,
Some("lighter") if inherited < 900 => 700,
Some("lighter") => 700,
Some(weight) => weight
.parse::<f32>()
.ok()
.filter(|weight| weight.is_finite())
.map(|weight| weight.round().clamp(1.0, 1000.0) as u16)
.unwrap_or(inherited),
}
}
pub(crate) fn used_font_weight(style: &LayoutStyle) -> u16 {
computed_font_weight(style.font_weight.as_deref(), 400)
}
fn is_font_size_token(value: &str) -> bool {
let lower = value.trim().to_ascii_lowercase();
if lower == "0" || font_size_keyword(&lower).is_some() {
return true;
}
if lower.starts_with("calc(")
|| lower.starts_with("min(")
|| lower.starts_with("max(")
|| lower.starts_with("clamp(")
{
return true;
}
[
"px", "pt", "em", "ex", "rem", "vw", "vh", "dvw", "dvh", "svw",
"svh", "lvw", "lvh", "vmin", "vmax", "%",
]
.iter()
.any(|unit| lower.strip_suffix(unit).and_then(|number| number.parse::<f32>().ok()).is_some())
}
fn dimension_value(tok: &str) -> crate::Dimension {
use crate::Dimension;
let n = tok.trim();
if n.eq_ignore_ascii_case("auto") || n.is_empty() {
return Dimension::Auto;
}
// calc()/min()/max()/var(): resolve context-free to px where possible
// (relative units inside are approximated; rare for these properties).
if n.contains('(') {
return px(n).map(Dimension::Px).unwrap_or(Dimension::Auto);
}
let lower = n.to_ascii_lowercase();
let parse = |s: &str| s.trim().parse::<f32>().ok();
if let Some(v) = lower.strip_suffix('%').and_then(parse) {
return Dimension::Percent(v / 100.0);
}
// Order matters: check `rem` before `em`, `vmin`/`vmax` before `vw`/`vh`.
if let Some(v) = lower.strip_suffix("rem").and_then(parse) { return Dimension::Rem(v); }
if let Some(v) = lower.strip_suffix("em").and_then(parse) { return Dimension::Em(v); }
if let Some(v) = lower.strip_suffix("ex").and_then(parse) { return Dimension::Ex(v); }
if let Some(v) = lower.strip_suffix("vmin").and_then(parse) { return Dimension::Vmin(v); }
if let Some(v) = lower.strip_suffix("vmax").and_then(parse) { return Dimension::Vmax(v); }
if let Some(v) = lower
.strip_suffix("dvw")
.or_else(|| lower.strip_suffix("svw"))
.or_else(|| lower.strip_suffix("lvw"))
.and_then(parse)
{
return Dimension::Vw(v);
}
if let Some(v) = lower
.strip_suffix("dvh")
.or_else(|| lower.strip_suffix("svh"))
.or_else(|| lower.strip_suffix("lvh"))
.and_then(parse)
{
return Dimension::Vh(v);
}
if let Some(v) = lower.strip_suffix("vw").and_then(parse) { return Dimension::Vw(v); }
if let Some(v) = lower.strip_suffix("vh").and_then(parse) { return Dimension::Vh(v); }
if let Some(v) = lower.strip_suffix("px").and_then(parse) { return Dimension::Px(v); }
if let Some(v) = lower.strip_suffix("pt").and_then(parse) { return Dimension::Px(v * 1.333); }
// CSS lengths accept a unitless number only when it is zero. Treating
// arbitrary numbers as pixels changes invalid declarations into tiny
// geometry (for example `font-size:.813` must be ignored and inherited,
// not rendered at 0.813px).
if let Some(v) = parse(&lower).filter(|v| *v == 0.0) {
return Dimension::Px(v);
}
Dimension::Auto
}
/// Parse the currently painted basic-shape subset of `clip-path`.
///
/// The default (and only supported) geometry box is `border-box`. Rejecting
/// the other reference boxes here keeps `@supports` aligned with the geometry
/// we actually paint. Functional coordinates are likewise rejected until
/// their unresolved math can be retained through computed style.
fn parse_clip_path_polygon(value: &str) -> Option<crate::ClipPathPolygon> {
let value = value.trim();
let lower = value.to_ascii_lowercase();
if !lower.starts_with("polygon(") {
return None;
}
let close = value.rfind(')')?;
let suffix = value[close + 1..].trim();
if !suffix.is_empty() && !suffix.eq_ignore_ascii_case("border-box") {
return None;
}
let inner = value["polygon(".len()..close].trim();
let mut components = split_top_level(inner, ',');
let mut fill_rule = crate::ClipPathFillRule::Nonzero;
if let Some(first) = components.first().map(|component| component.trim()) {
if first.eq_ignore_ascii_case("evenodd") {
fill_rule = crate::ClipPathFillRule::Evenodd;
components.remove(0);
} else if first.eq_ignore_ascii_case("nonzero") {
components.remove(0);
}
}
if components.is_empty() {
return None;
}
let mut points = Vec::with_capacity(components.len());
for component in components {
let coordinates = split_ws_paren(component);
let [x, y] = coordinates.as_slice() else {
return None;
};
if x.contains('(') || y.contains('(') {
return None;
}
let x = dimension_value(x);
let y = dimension_value(y);
if matches!(x, crate::Dimension::Auto) || matches!(y, crate::Dimension::Auto) {
return None;
}
let finite = |coordinate: crate::Dimension| match coordinate {
crate::Dimension::Auto => false,
crate::Dimension::Px(value)
| crate::Dimension::Percent(value)
| crate::Dimension::Em(value)
| crate::Dimension::Ex(value)
| crate::Dimension::Rem(value)
| crate::Dimension::Vw(value)
| crate::Dimension::Vh(value)
| crate::Dimension::Vmin(value)
| crate::Dimension::Vmax(value) => value.is_finite(),
};
if !finite(x) || !finite(y) {
return None;
}
points.push((x, y));
}
Some(crate::ClipPathPolygon { fill_rule, points })
}
/// Parse every length token in a value as px (for box shorthands).
fn edges(value: &str) -> Option<Edges> {
// Keep functional lengths intact while tokenizing. Design systems commonly
// expose border widths through custom properties such as
// `--stroke:calc(1*1px)`; after var() substitution that calc is still one
// valid border-width token. Splitting on every internal space and calling
// px_value() dropped it, leaving an otherwise fully-authored control with
// no painted border.
let dims: Vec<f32> = split_ws_paren(value)
.into_iter()
.map(px)
.collect::<Option<Vec<_>>>()?;
edges_from(dims)
}
/// Split a 1-or-2 value shorthand into (start, end); a single value applies to
/// both. Used by the logical-property axes (`margin-inline`, `padding-block`).
fn two(value: &str) -> (&str, &str) {
let values = split_ws_paren(value);
let a = values.first().copied().unwrap_or("0");
let b = values.get(1).copied().unwrap_or(a);
(a, b)
}
/// Set one margin side (0=top,1=right,2=bottom,3=left), tracking `auto` and, for
/// a percentage, deferring resolution to the containing-block width in the
/// top-down pass (recorded in `margin_percent`).
fn set_margin_side(style: &mut LayoutStyle, idx: usize, value: &str) {
let v = value.trim();
let is_auto = v.eq_ignore_ascii_case("auto");
if let Some(expression) = deferred_length_expression(v) {
style.margin_expressions[idx] = Some(expression);
style.margin_percent[idx] = None;
style.margin_relative[idx] = None;
style.margin_auto[idx] = false;
set_margin_px(&mut style.margin, idx, 0.0);
return;
}
style.margin_expressions[idx] = None;
if let Some(frac) = percent_fraction(v) {
style.margin_percent[idx] = Some(frac);
style.margin_relative[idx] = None;
set_margin_px(&mut style.margin, idx, 0.0);
style.margin_auto[idx] = false;
return;
}
let dimension = dimension_value(v);
match dimension {
crate::Dimension::Px(px) => {
set_margin_px(&mut style.margin, idx, px);
style.margin_relative[idx] = None;
}
crate::Dimension::Em(_)
| crate::Dimension::Ex(_)
| crate::Dimension::Rem(_)
| crate::Dimension::Vw(_)
| crate::Dimension::Vh(_)
| crate::Dimension::Vmin(_)
| crate::Dimension::Vmax(_) => {
set_margin_px(&mut style.margin, idx, 0.0);
style.margin_relative[idx] = Some(dimension);
}
_ => {
set_margin_px(&mut style.margin, idx, 0.0);
style.margin_relative[idx] = None;
}
}
style.margin_auto[idx] = is_auto;
style.margin_percent[idx] = None;
}
fn set_margin_px(margin: &mut Edges, idx: usize, px: f32) {
match idx {
0 => margin.top = px,
1 => margin.right = px,
2 => margin.bottom = px,
3 => margin.left = px,
_ => {}
}
}
/// Set one padding side (0=top,1=right,2=bottom,3=left). A percentage is
/// recorded in `padding_percent` and resolved against the containing-block
/// width during the top-down pass; a length is stored directly.
fn set_padding_side(style: &mut LayoutStyle, idx: usize, value: &str) {
let value = value.trim();
if let Some(expression) = deferred_length_expression(value) {
style.padding_expressions[idx] = Some(expression);
style.padding_percent[idx] = None;
style.padding_relative[idx] = None;
set_padding_px(&mut style.padding, idx, 0.0);
return;
}
style.padding_expressions[idx] = None;
if let Some(frac) = percent_fraction(value) {
style.padding_percent[idx] = Some(frac);
style.padding_relative[idx] = None;
set_padding_px(&mut style.padding, idx, 0.0);
return;
}
let dimension = dimension_value(value);
match dimension {
crate::Dimension::Px(px) => {
set_padding_px(&mut style.padding, idx, px);
style.padding_relative[idx] = None;
style.padding_percent[idx] = None;
}
crate::Dimension::Em(_)
| crate::Dimension::Ex(_)
| crate::Dimension::Rem(_)
| crate::Dimension::Vw(_)
| crate::Dimension::Vh(_)
| crate::Dimension::Vmin(_)
| crate::Dimension::Vmax(_) => {
set_padding_px(&mut style.padding, idx, 0.0);
style.padding_relative[idx] = Some(dimension);
style.padding_percent[idx] = None;
}
_ => {}
}
}
fn set_padding_px(padding: &mut Edges, idx: usize, px: f32) {
match idx {
0 => padding.top = px,
1 => padding.right = px,
2 => padding.bottom = px,
3 => padding.left = px,
_ => {}
}
}
/// `padding: <t> <r>? <b>? <l>?`, percentage-aware per side.
fn apply_padding_shorthand(style: &mut LayoutStyle, value: &str) {
let toks = split_ws_paren(value);
let (t, r, b, l) = match toks.as_slice() {
[a] => (*a, *a, *a, *a),
[v, h] => (*v, *h, *v, *h),
[t, h, b] => (*t, *h, *b, *h),
[t, r, b, l, ..] => (*t, *r, *b, *l),
[] => return,
};
set_padding_side(style, 0, t);
set_padding_side(style, 1, r);
set_padding_side(style, 2, b);
set_padding_side(style, 3, l);
}
/// A bare `<number>%` token as a 0..1 fraction (`56.25%` -> `0.5625`). Returns
/// `None` for anything that is not a plain percentage (lengths, `calc(...%)`,
/// keywords), so those keep their existing length handling.
fn percent_fraction(tok: &str) -> Option<f32> {
let num = tok.trim().strip_suffix('%')?;
let v: f32 = num.trim().parse().ok()?;
if v.is_finite() {
Some(v / 100.0)
} else {
None
}
}
/// `margin: <t> <r>? <b>? <l>?` with per-side `auto` (so `margin: 0 auto`
/// centers).
fn apply_margin_shorthand(style: &mut LayoutStyle, value: &str) {
let toks = split_ws_paren(value);
let (t, r, b, l) = match toks.as_slice() {
[a] => (*a, *a, *a, *a),
[v, h] => (*v, *h, *v, *h),
[t, h, b] => (*t, *h, *b, *h),
[t, r, b, l, ..] => (*t, *r, *b, *l),
[] => return,
};
set_margin_side(style, 0, t);
set_margin_side(style, 1, r);
set_margin_side(style, 2, b);
set_margin_side(style, 3, l);
}
fn px_value(tok: &str) -> Option<f32> {
let mut n = tok;
let mut scale = 1.0;
if n.ends_with("px") {
n = &n[..n.len() - 2];
} else if n.ends_with("pt") {
n = &n[..n.len() - 2];
scale = 1.333; // 1pt ≈ 1.333px
} else if n.ends_with("em") || n.ends_with("rem") {
n = n.trim_end_matches(|c: char| c.is_ascii_alphabetic());
scale = 16.0; // 1em = 16px
} else if n.ends_with("ex") {
n = n.trim_end_matches(|c: char| c.is_ascii_alphabetic());
scale = 16.0 * 0.528_320_3;
} else if n.ends_with('%') {
n = &n[..n.len() - 1];
scale = 16.0 / 100.0;
} else {
n = n.trim_end_matches(|c: char| c.is_ascii_alphabetic());
}
if n.chars()
.any(|c| !(c.is_ascii_digit() || c == '.' || c == '-'))
{
return None;
}
n.parse::<f32>().ok().map(|v| v * scale)
}
fn token(value: &str) -> Option<&str> {
value.split_whitespace().next()
}
fn set_background_gradients(style: &mut LayoutStyle, value: &str) {
let layers = parse_background_gradient_layers(value, style.color_scheme_dark);
style.background_gradient = layers.iter().find_map(|layer| match layer {
crate::BackgroundGradientLayer::Linear { angle, stops, .. } => {
Some((*angle, stops.clone()))
}
_ => None,
});
style.background_radial_gradient = layers.iter().find_map(|layer| match layer {
crate::BackgroundGradientLayer::Radial { center, stops } => Some((*center, stops.clone())),
_ => None,
});
style.background_conic_gradient = layers.iter().find_map(|layer| match layer {
crate::BackgroundGradientLayer::Conic {
angle,
center,
stops,
} => Some((*angle, *center, stops.clone())),
_ => None,
});
style.background_gradient_layers = layers;
}
fn parse_background_gradient_layers(
value: &str,
dark_scheme: bool,
) -> Vec<crate::BackgroundGradientLayer> {
let mut layers = Vec::new();
for authored_layer in split_top_level(value, ',') {
if let Some(linear) = parse_linear_gradient(authored_layer, dark_scheme) {
layers.push(crate::BackgroundGradientLayer::Linear {
angle: linear.angle,
stops: linear.stops,
stop_positions: linear.stop_positions,
repeating: linear.repeating,
});
} else if let Some((center, stops)) = parse_radial_gradient(authored_layer, dark_scheme) {
layers.push(crate::BackgroundGradientLayer::Radial { center, stops });
} else if let Some((angle, center, stops)) =
parse_conic_gradient(authored_layer, dark_scheme)
{
layers.push(crate::BackgroundGradientLayer::Conic {
angle,
center,
stops,
});
}
}
layers
}
struct ParsedLinearGradient {
angle: f32,
stops: Vec<([u8; 4], Option<f32>)>,
stop_positions: Vec<Option<String>>,
repeating: bool,
}
/// Parse a `linear-gradient(...)` (also `repeating-`/`-webkit-`/`-moz-`) into
/// (angle-degrees, color stops). Angle is CSS convention (0deg = to top, grows
/// clockwise); `to <side>` keywords map to their angle. Color stops keep their
/// optional 0..1 position. Returns None if it is not a linear-gradient or has
/// no parseable colors. Radial/conic gradients are not handled (None).
fn parse_linear_gradient(value: &str, dark_scheme: bool) -> Option<ParsedLinearGradient> {
let v = value.trim();
let lower = v.to_ascii_lowercase();
let start = lower.find("linear-gradient(")?;
let repeating = lower[..start].ends_with("repeating-");
// The original prefixed WebKit gradient syntax predates the standardized
// angle system: 0deg points right and positive angles turn
// counter-clockwise. Blink still accepts that syntax for compatibility.
// Convert it to our standard CSS angle (0deg up, clockwise) before paint.
let prefix = &lower[..start];
let legacy_webkit_angle =
prefix.ends_with("-webkit-") || prefix.ends_with("-webkit-repeating-");
let open = start + "linear-gradient(".len();
// Match the closing paren for this function.
let bytes = v.as_bytes();
let mut depth = 1;
let mut end = open;
while end < bytes.len() && depth > 0 {
match bytes[end] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
end += 1;
}
let inner = &v[open..end.saturating_sub(1)];
// Split on top-level commas (respect rgb()/rgba()/hsl() parens).
let mut parts: Vec<String> = Vec::new();
let mut cur = String::new();
let mut d = 0i32;
for c in inner.chars() {
match c {
'(' => { d += 1; cur.push(c); }
')' => { d -= 1; cur.push(c); }
',' if d == 0 => { parts.push(std::mem::take(&mut cur)); }
_ => cur.push(c),
}
}
if !cur.trim().is_empty() {
parts.push(cur);
}
if parts.is_empty() {
return None;
}
// Leading angle / direction, if present.
let mut angle = 180.0f32; // default: to bottom
let first = parts[0].trim().to_ascii_lowercase();
let mut stop_start = 0;
if first.ends_with("deg") {
if let Ok(a) = first.trim_end_matches("deg").trim().parse::<f32>() {
angle = if legacy_webkit_angle {
(90.0 - a).rem_euclid(360.0)
} else {
a.rem_euclid(360.0)
};
}
stop_start = 1;
} else if first.starts_with("to ") {
angle = match first.as_str() {
"to top" => 0.0, "to right" => 90.0, "to bottom" => 180.0, "to left" => 270.0,
"to top right" | "to right top" => 45.0, "to bottom right" | "to right bottom" => 135.0,
"to bottom left" | "to left bottom" => 225.0, "to top left" | "to left top" => 315.0,
_ => 180.0,
};
stop_start = 1;
} else if first.starts_with("turn") || first.ends_with("turn") {
stop_start = 1;
}
let mut stops: Vec<([u8; 4], Option<f32>)> = Vec::new();
let mut stop_positions = Vec::new();
for p in &parts[stop_start..] {
let t = p.trim();
if t.is_empty() {
continue;
}
let tokens = split_ws_paren(t);
let parsed = (1..=tokens.len()).find_map(|color_tokens| {
let position_tokens = &tokens[color_tokens..];
if position_tokens.len() > 2
|| !position_tokens
.iter()
.all(|position| gradient_position_is_valid(position))
{
return None;
}
let color = tokens[..color_tokens].join(" ");
parse_color_for_scheme(&color, dark_scheme).map(|color| (color, position_tokens))
});
if let Some((color, positions)) = parsed {
if positions.is_empty() {
stops.push((color, None));
stop_positions.push(None);
} else {
for position in positions {
let percentage = position
.strip_suffix('%')
.and_then(|number| number.parse::<f32>().ok())
.map(|percentage| percentage / 100.0);
stops.push((color, percentage));
stop_positions.push(Some(position.trim().to_string()));
}
}
}
}
if stops.len() < 2 {
// A single-color "gradient" is just that color; let the caller fall back
// to background_color instead (return None so parse_color runs).
return None;
}
Some(ParsedLinearGradient {
angle,
stops,
stop_positions,
repeating,
})
}
fn gradient_position_is_valid(value: &str) -> bool {
let value = value.trim();
if value == "0" || value == "-0" || value.contains('(') {
return true;
}
[
"%", "px", "em", "rem", "ex", "vw", "vh", "vmin", "vmax", "dvw", "dvh", "svw", "svh",
"lvw", "lvh",
]
.iter()
.any(|suffix| {
value
.strip_suffix(suffix)
.and_then(|number| number.trim().parse::<f32>().ok())
.is_some_and(f32::is_finite)
})
}
fn parse_radial_gradient(
value: &str,
dark_scheme: bool,
) -> Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)> {
let lower = value.to_ascii_lowercase();
let start = lower.find("radial-gradient(")?;
let open = start + "radial-gradient(".len();
let end = find_matching_paren(&value[open..])? + open;
let parts = split_top_level(&value[open..end], ',');
if parts.is_empty() {
return None;
}
let mut center = (0.5, 0.5);
let mut stop_start = 0;
let prelude = parts[0].trim().to_ascii_lowercase();
if prelude.contains(" at ") || prelude.starts_with("at ") {
let coords = prelude
.split_once(" at ")
.map(|(_, coords)| coords)
.or_else(|| prelude.strip_prefix("at "))
.unwrap_or_default();
center = parse_gradient_center(coords);
stop_start = 1;
} else if parse_color_for_scheme(parts[0].trim(), dark_scheme).is_none() {
stop_start = 1;
}
let mut stops = Vec::new();
for part in &parts[stop_start..] {
let (color, position) = split_color_stop(part.trim());
if let Some(color) = parse_color_for_scheme(color, dark_scheme) {
stops.push((color, position));
}
}
(stops.len() >= 2).then_some((center, stops))
}
fn parse_gradient_center(value: &str) -> (f32, f32) {
let mut center = (0.5, 0.5);
let mut percentages = value.split_whitespace().filter_map(percent_fraction);
if let Some(x) = percentages.next() {
center.0 = x;
}
if let Some(y) = percentages.next() {
center.1 = y;
}
for token in value.split_whitespace() {
match token {
"left" => center.0 = 0.0,
"right" => center.0 = 1.0,
"top" => center.1 = 0.0,
"bottom" => center.1 = 1.0,
// `center` leaves the corresponding unspecified axis at 50%.
_ => {}
}
}
center
}
/// Parse the common `conic-gradient(from A at X Y, color P%, ...)` form.
/// Angles follow CSS convention (0deg at 12 o'clock, clockwise); the center
/// is retained as box-relative fractions for paint-time resolution.
fn parse_conic_gradient(
value: &str,
dark_scheme: bool,
) -> Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)> {
let v = value.trim();
let lower = v.to_ascii_lowercase();
let start = lower.find("conic-gradient(")?;
let open = start + "conic-gradient(".len();
let end = find_matching_paren(&v[open..])? + open;
let inner = &v[open..end];
let parts = split_top_level(inner, ',');
if parts.is_empty() {
return None;
}
let mut angle = 0.0f32;
let mut center = (0.5f32, 0.5f32);
let mut stop_start = 0usize;
let prelude = parts[0].trim().to_ascii_lowercase();
if prelude.starts_with("from ") || prelude.starts_with("at ") {
if let Some(from) = prelude.find("from ") {
let token = prelude[from + 5..]
.split_whitespace()
.next()
.unwrap_or_default();
angle = parse_css_angle(token).unwrap_or(0.0).rem_euclid(360.0);
}
if let Some(at) = prelude.find(" at ") {
let coords: Vec<&str> = prelude[at + 4..].split_whitespace().collect();
if let Some(x) = coords.first().and_then(|value| percent_fraction(value)) {
center.0 = x;
}
if let Some(y) = coords.get(1).and_then(|value| percent_fraction(value)) {
center.1 = y;
}
} else if let Some(at) = prelude.strip_prefix("at ") {
let coords: Vec<&str> = at.split_whitespace().collect();
if let Some(x) = coords.first().and_then(|value| percent_fraction(value)) {
center.0 = x;
}
if let Some(y) = coords.get(1).and_then(|value| percent_fraction(value)) {
center.1 = y;
}
}
stop_start = 1;
}
let mut stops = Vec::new();
for part in &parts[stop_start..] {
let part = part.trim();
if part.is_empty() {
continue;
}
let (color, position) = split_color_stop(part);
if let Some(color) = parse_color_for_scheme(color, dark_scheme) {
stops.push((color, position));
}
}
(stops.len() >= 2).then_some((angle, center, stops))
}
fn parse_css_angle(value: &str) -> Option<f32> {
let value = value.trim();
if let Some(degrees) = value.strip_suffix("deg") {
return degrees.trim().parse::<f32>().ok();
}
if let Some(turns) = value.strip_suffix("turn") {
return turns.trim().parse::<f32>().ok().map(|turns| turns * 360.0);
}
if let Some(gradians) = value.strip_suffix("grad") {
return gradians.trim().parse::<f32>().ok().map(|grad| grad * 0.9);
}
if let Some(radians) = value.strip_suffix("rad") {
return radians
.trim()
.parse::<f32>()
.ok()
.map(f32::to_degrees);
}
None
}
fn split_color_stop(value: &str) -> (&str, Option<f32>) {
if let Some(idx) = value.rfind(char::is_whitespace) {
let tail = value[idx + 1..].trim();
if let Some(percent) = tail
.strip_suffix('%')
.and_then(|number| number.parse::<f32>().ok())
{
return (
value[..idx].trim(),
Some((percent / 100.0).clamp(0.0, 1.0)),
);
}
if let Some(degrees) = tail
.strip_suffix("deg")
.and_then(|number| number.parse::<f32>().ok())
{
return (
value[..idx].trim(),
Some((degrees / 360.0).clamp(0.0, 1.0)),
);
}
}
(value, None)
}
/// Parse `aspect-ratio` to a width/height ratio. Accepts `16 / 9`, `1.5`, and
/// the `auto <ratio>` form (the `auto` keyword alone yields `None`, meaning the
/// intrinsic ratio, which for images is filled in at layout).
fn parse_aspect_ratio(value: &str) -> Option<f32> {
let v = value.trim();
if v.eq_ignore_ascii_case("auto") {
return None;
}
// Drop a leading/trailing `auto` keyword from the `auto <ratio>` form.
let ratio_part: String = v
.split_whitespace()
.filter(|t| !t.eq_ignore_ascii_case("auto"))
.collect::<Vec<_>>()
.join(" ");
if let Some((w, h)) = ratio_part.split_once('/') {
let w: f32 = w.trim().parse().ok()?;
let h: f32 = h.trim().parse().ok()?;
if h > 0.0 && w > 0.0 {
return Some(w / h);
}
return None;
}
let r: f32 = ratio_part.trim().parse().ok()?;
(r.is_finite() && r > 0.0).then_some(r)
}
/// Extract the first `url(...)` reference from a `background`/`background-image`
/// value, unquoted. Ignores any other layers in the same shorthand (gradients,
/// `no-repeat`, etc.): we paint the referenced image, not the gradient.
fn parse_url(value: &str) -> Option<String> {
let start = value.find("url(")? + 4;
let mut depth = 1i32;
let mut quote: Option<char> = None;
let mut escaped = false;
let mut end = None;
for (offset, character) in value[start..].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(start + offset);
break;
}
}
_ => {}
}
}
let inner = value[start..end?].trim();
let unquoted = inner.trim_matches(|c| c == '"' || c == '\'');
if unquoted.is_empty() {
None
} else {
Some(unquoted.to_string())
}
}
/// `flex: none|auto|<grow>|<grow> <shrink>|<grow> <shrink> <basis>`. This is
/// the shorthand form almost all real-world flexbox CSS actually uses (far
/// more often than the flex-grow/flex-shrink longhands); leaving it
/// unhandled silently drops grow/shrink from every rule written this way.
/// flex-basis is not modeled as a distinct field from width, so a basis
/// length in the shorthand is parsed (to keep the number-only forms working)
/// but otherwise not separately applied; auto is a reasonable approximation
/// for the common case where basis is 0 or unspecified.
fn parse_flex_shorthand(style: &mut LayoutStyle, value: &str) {
match value.trim() {
"none" => {
style.flex_grow = Some(0.0);
style.flex_shrink = Some(0.0);
style.flex_basis = crate::Dimension::Auto;
return;
}
"auto" => {
style.flex_grow = Some(1.0);
style.flex_shrink = Some(1.0);
style.flex_basis = crate::Dimension::Auto;
return;
}
"initial" => {
style.flex_grow = Some(0.0);
style.flex_shrink = Some(1.0);
style.flex_basis = crate::Dimension::Auto;
return;
}
_ => {}
}
// Grammar: `flex: <grow> <shrink>? || <basis>`. Bare numbers are grow then
// shrink; a token with a unit / `auto` / a third numeric is the basis
// (e.g. `flex: 0 0 260px`, the fixed-width sidebar idiom).
let mut numbers: Vec<f32> = Vec::new();
let mut basis: Option<crate::Dimension> = None;
for tok in value.split_whitespace() {
if let Ok(n) = tok.parse::<f32>() {
if numbers.len() < 2 {
numbers.push(n);
} else {
basis = Some(dimension_value(tok));
}
} else {
basis = Some(dimension_value(tok));
}
}
match numbers.as_slice() {
[grow] => {
style.flex_grow = Some(*grow);
style.flex_shrink = Some(1.0);
}
[grow, shrink, ..] => {
style.flex_grow = Some(*grow);
style.flex_shrink = Some(*shrink);
}
[] => {}
}
// Explicit basis wins; otherwise numbers-only shorthand implies basis 0
// (per spec `flex: 1` == `1 1 0%`), while a bare basis keeps grow/shrink 1.
style.flex_basis = match basis {
Some(b) => b,
None if !numbers.is_empty() => crate::Dimension::Px(0.0),
None => {
style.flex_grow = Some(1.0);
style.flex_shrink = Some(1.0);
crate::Dimension::Auto
}
};
}
/// `background-size: 10px` / `0.857em` / `10px 20px` -> explicit px pair.
/// Keyword values (`cover`, `contain`, `auto`) are left unhandled (`None`, the
/// "stretch to fill the box" fallback) since evaluating them needs the
/// image's own intrinsic aspect ratio, which is not known until it is
/// fetched, well after style resolution.
fn parse_background_size(value: &str) -> Option<(f32, f32)> {
let tokens: Vec<&str> = value.split_whitespace().collect();
match tokens.as_slice() {
[one] => px_value(one).map(|v| (v, v)),
[w, h] => Some((px_value(w)?, px_value(h)?)),
_ => None,
}
}
fn parse_background_size_fit(value: &str) -> Option<crate::ObjectFit> {
let size = value.rsplit_once('/').map_or(value, |(_, size)| size);
if size.split_whitespace().any(|token| token == "cover") {
Some(crate::ObjectFit::Cover)
} else if size.split_whitespace().any(|token| token == "contain") {
Some(crate::ObjectFit::Contain)
} else {
None
}
}
fn parse_image_repeat(value: &str) -> Option<(bool, bool)> {
let tokens: Vec<&str> = split_ws_paren(value)
.into_iter()
.filter(|token| {
matches!(
token.to_ascii_lowercase().as_str(),
"repeat" | "no-repeat" | "repeat-x" | "repeat-y" | "space" | "round"
)
})
.collect();
match tokens.as_slice() {
[token, ..] if token.eq_ignore_ascii_case("repeat-x") => Some((true, false)),
[token, ..] if token.eq_ignore_ascii_case("repeat-y") => Some((false, true)),
[x, y, ..] => Some((
!x.eq_ignore_ascii_case("no-repeat"),
!y.eq_ignore_ascii_case("no-repeat"),
)),
[both] => {
let repeat = !both.eq_ignore_ascii_case("no-repeat");
Some((repeat, repeat))
}
_ => None,
}
}
fn background_size_expression(value: &str) -> Option<String> {
let (_, size) = value.rsplit_once('/')?;
let mut depth = 0i32;
let mut end = size.len();
for (index, ch) in size.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = (depth - 1).max(0),
_ if depth == 0 && ch == ',' => {
end = index;
break;
}
_ => {}
}
}
let size = size[..end].trim();
(!size.is_empty()).then(|| size.to_string())
}
/// Parse the first layer of `background-position`.
///
/// Percentages are retained as fractions of the space left after sizing the
/// image, while lengths remain start-edge offsets. This distinction is
/// essential for CSS sprites: centering a 48px image in a 24px owner moves it
/// by -12px, whereas `background-position:0` must leave it at the start edge
/// and `background-position:-24px` must select its second 24px frame.
fn parse_background_position(value: &str) -> crate::BackgroundPosition {
use crate::{BackgroundPosition, BackgroundPositionAxis};
let first_layer = split_top_level(value, ',')
.into_iter()
.next()
.unwrap_or(value)
.trim();
let tokens = split_ws_paren(first_layer);
let center = BackgroundPositionAxis::percentage(0.5);
let start = BackgroundPositionAxis::percentage(0.0);
let end = BackgroundPositionAxis::percentage(1.0);
let numeric = |token: &str| {
let token = token.trim();
if let Some(number) = token.strip_suffix('%') {
let percentage = number.parse::<f32>().ok()? / 100.0;
return percentage
.is_finite()
.then(|| BackgroundPositionAxis::percentage(percentage));
}
px_value(token)
.filter(|length| length.is_finite())
.map(BackgroundPositionAxis::pixels)
};
let horizontal_keyword = |token: &str| match token {
"left" => Some(start),
"right" => Some(end),
_ => None,
};
let vertical_keyword = |token: &str| match token {
"top" => Some(start),
"bottom" => Some(end),
_ => None,
};
match tokens.as_slice() {
[] => BackgroundPosition::default(),
[one] => {
if let Some(y) = vertical_keyword(one) {
BackgroundPosition::new(center, y)
} else {
let x = horizontal_keyword(one)
.or_else(|| (*one == "center").then_some(center))
.or_else(|| numeric(one))
.unwrap_or(center);
BackgroundPosition::new(x, center)
}
}
[first, second] => {
if let Some(y) = vertical_keyword(first) {
let x = horizontal_keyword(second)
.or_else(|| (*second == "center").then_some(center))
.unwrap_or(center);
return BackgroundPosition::new(x, y);
}
if let Some(x) = horizontal_keyword(first) {
let y = vertical_keyword(second)
.or_else(|| (*second == "center").then_some(center))
.or_else(|| numeric(second))
.unwrap_or(center);
return BackgroundPosition::new(x, y);
}
if let Some(x) = horizontal_keyword(second) {
let y = vertical_keyword(first)
.or_else(|| (*first == "center").then_some(center))
.unwrap_or(center);
return BackgroundPosition::new(x, y);
}
let x = (*first == "center")
.then_some(center)
.or_else(|| numeric(first))
.unwrap_or(center);
let y = vertical_keyword(second)
.or_else(|| (*second == "center").then_some(center))
.or_else(|| numeric(second))
.unwrap_or(center);
BackgroundPosition::new(x, y)
}
_ => {
// Three/four-value syntax anchors an offset to a named edge:
// `right 10px bottom 20px` => `calc(100% - 10px)
// calc(100% - 20px)`.
let mut x = None;
let mut y = None;
let mut index = 0;
while index < tokens.len() {
let token = tokens[index];
let (axis, from_end) = match token {
"left" => (Some(false), false),
"right" => (Some(false), true),
"top" => (Some(true), false),
"bottom" => (Some(true), true),
"center" => {
if x.is_none() {
x = Some(center);
} else if y.is_none() {
y = Some(center);
}
index += 1;
continue;
}
_ => (None, false),
};
if let Some(vertical) = axis {
let offset = tokens.get(index + 1).and_then(|next| numeric(next));
let position = match offset {
Some(offset) if from_end => BackgroundPositionAxis::from_end_offset(offset),
Some(offset) => offset,
None if from_end => end,
None => start,
};
if vertical {
y = Some(position);
} else {
x = Some(position);
}
index += usize::from(offset.is_some());
} else if let Some(position) = numeric(token) {
if x.is_none() {
x = Some(position);
} else if y.is_none() {
y = Some(position);
}
}
index += 1;
}
BackgroundPosition::new(x.unwrap_or(center), y.unwrap_or(center))
}
}
}
/// Parse a `box-shadow` value into its first layer:
/// `[inset]? <offset-x> <offset-y> <blur>? <spread>? <color>?`. The `inset`
/// keyword and the color may each lead or trail the lengths; comma-separated
/// multiples are accepted but only the first layer is stored. `current_color`
/// supplies the default when the color is omitted (CSS `currentColor`).
fn parse_box_shadow(
value: &str,
current_color: Option<[u8; 4]>,
dark_scheme: bool,
) -> Option<crate::BoxShadow> {
let v = value.trim();
if v.is_empty() || v.eq_ignore_ascii_case("none") {
return None;
}
// Only the first comma-separated layer is modeled; split at paren depth 0 so
// the commas inside an rgba()/hsl() color are not treated as separators.
let layer = split_top_level(v, ',').into_iter().next()?;
let mut inset = false;
let mut color: Option<[u8; 4]> = None;
let mut lengths: Vec<f32> = Vec::new();
for tok in split_ws_paren(layer.trim()) {
let t = tok.trim();
if t.is_empty() {
continue;
}
if t.eq_ignore_ascii_case("inset") {
inset = true;
continue;
}
// Try a length first so a bare `0` is an offset, not a failed color;
// px_value rejects color tokens (`#ccc`, `red`, `rgba(...)`) so they
// fall through to parse_color.
if lengths.len() < 4 {
if let Some(px) = px_value(t) {
lengths.push(px);
continue;
}
}
if let Some(c) = parse_color_for_scheme(t, dark_scheme) {
color = Some(c);
}
}
if lengths.len() < 2 {
return None;
}
Some(crate::BoxShadow {
offset_x: lengths[0],
offset_y: lengths[1],
blur: lengths.get(2).copied().unwrap_or(0.0),
spread: lengths.get(3).copied().unwrap_or(0.0),
color: color.or(current_color).unwrap_or([0, 0, 0, 255]),
inset,
})
}
/// Split on ASCII whitespace at paren depth 0, so a functional color like
/// `rgba(0, 0, 0, .15)` (whose internal spaces are not token separators) stays
/// one token.
fn split_ws_paren(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut start: Option<usize> = None;
for (i, c) in s.char_indices() {
if c.is_whitespace() && depth == 0 {
if let Some(st) = start.take() {
out.push(&s[st..i]);
}
continue;
}
match c {
'(' => depth += 1,
')' => depth = (depth - 1).max(0),
_ => {}
}
if start.is_none() {
start = Some(i);
}
}
if let Some(st) = start {
out.push(&s[st..]);
}
out
}
/// CSS box shorthand: 1 value applies to all sides; 2 values are (v, h);
/// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left).
fn edges_from(dims: Vec<f32>) -> Option<Edges> {
match dims.len() {
0 => None,
1 => Some(Edges { top: dims[0], right: dims[0], bottom: dims[0], left: dims[0] }),
2 => Some(Edges { top: dims[0], right: dims[1], bottom: dims[0], left: dims[1] }),
3 => Some(Edges { top: dims[0], right: dims[1], bottom: dims[2], left: dims[1] }),
_ => Some(Edges { top: dims[0], right: dims[1], bottom: dims[2], left: dims[3] }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clip_path_polygon_supports_only_geometry_it_can_resolve() {
assert!(supports_declaration(
"clip-path",
"polygon(0 0, 100% 0, 50% 90px)"
));
assert!(supports_declaration(
"-webkit-clip-path",
"polygon(evenodd, 0 0, 10rem 0, 10rem 10vh) border-box"
));
for unsupported in [
"polygon(0 0, 100% 0, 50% 100%) content-box",
"content-box polygon(0 0, 100% 0, 50% 100%)",
"polygon(0, 100% 0)",
"polygon(evenodd 0 0, 100% 0)",
"polygon(0 0, calc(100% - 1px) 0, 0 100%)",
"circle(50%)",
] {
assert!(
!supports_declaration("clip-path", unsupported),
"@supports must not advertise unpainted clip geometry: {unsupported}"
);
}
let style = compute_style(
"div",
Some("clip-path:polygon(evenodd, -10px 0, 100% 0, 50% 2em)"),
);
let polygon = style.clip_path.expect("computed polygon");
assert_eq!(polygon.fill_rule, crate::ClipPathFillRule::Evenodd);
assert_eq!(
polygon.points,
vec![
(crate::Dimension::Px(-10.0), crate::Dimension::Px(0.0)),
(crate::Dimension::Percent(1.0), crate::Dimension::Px(0.0)),
(crate::Dimension::Percent(0.5), crate::Dimension::Em(2.0)),
]
);
}
#[test]
fn parses_display_and_size() {
let s = compute_style("div", Some("display: flex; width: 200px; height: 50px"));
assert_eq!(s.display, Display::Flex);
assert_eq!(s.width, crate::Dimension::Px(200.0));
assert_eq!(s.height, crate::Dimension::Px(50.0));
let flow_root = compute_style("div", Some("display: flow-root"));
assert_eq!(flow_root.display, Display::Block);
assert!(flow_root.flow_root);
let table = compute_style("div", Some("display:none; display:table"));
assert_eq!(table.display, Display::Block);
assert!(table.flow_root);
assert!(table.is_table_box);
let inline_table = compute_style("div", Some("display:block; display:inline-table"));
assert_eq!(inline_table.display, Display::Inline);
assert!(inline_table.is_inline_block);
assert!(inline_table.flow_root);
assert!(inline_table.is_table_box);
let reset_table = compute_style("div", Some("display:table; display:grid"));
assert_eq!(reset_table.display, Display::Grid);
assert!(!reset_table.is_table_box);
}
#[test]
fn parses_multicol_count_shorthand_and_break_avoidance() {
let shorthand = compute_style(
"div",
Some("columns: 240px 3; break-inside: avoid-column"),
);
assert_eq!(shorthand.column_count, Some(3));
assert!(shorthand.break_inside_avoid);
let reset = compute_style("div", Some("column-count: 4; columns: auto"));
assert_eq!(reset.column_count, None);
}
#[test]
fn animation_shorthand_parses_the_first_timing_contract() {
let finite = compute_style(
"div",
Some("animation: dismiss-overlay .6s ease-out forwards"),
);
assert_eq!(finite.animation_name.as_deref(), Some("dismiss-overlay"));
assert_eq!(finite.animation_timing.duration_ms, 600.0);
assert_eq!(finite.animation_timing.fill_mode, crate::AnimationFillMode::Forwards);
assert_eq!(finite.animation_timing.iteration_count, 1.0);
let infinite = compute_style(
"div",
Some("animation: pulse 1s linear infinite"),
);
assert_eq!(infinite.animation_name.as_deref(), Some("pulse"));
assert_eq!(infinite.animation_timing.duration_ms, 1000.0);
assert_eq!(infinite.animation_timing.fill_mode, crate::AnimationFillMode::None);
assert!(infinite.animation_timing.iteration_count.is_infinite());
let calculated = compute_style(
"div",
Some(
"animation:wave 1.2s linear infinite;\
animation-delay:calc(.1s * -2.5);\
animation-direction:alternate-reverse;\
animation-fill-mode:both;\
animation-play-state:paused",
),
);
assert_eq!(calculated.animation_timing.delay_ms, -250.0);
assert_eq!(
calculated.animation_timing.direction,
crate::AnimationDirection::AlternateReverse
);
assert_eq!(calculated.animation_timing.fill_mode, crate::AnimationFillMode::Both);
assert_eq!(calculated.animation_timing.play_state, crate::AnimationPlayState::Paused);
assert_eq!(parse_animation_time_ms("calc(1s / 2 + 25ms)"), Some(525.0));
assert_eq!(parse_animation_time_ms("1s * 2"), None);
let reset = compute_style(
"div",
Some("animation:fade 2s -1s 3 reverse both paused;animation:initial"),
);
assert_eq!(reset.animation_name, None);
assert_eq!(reset.animation_timing, crate::AnimationTiming::default());
}
#[test]
fn display_contents_overrides_an_earlier_display_none() {
let style = compute_style("div", Some("display:none; display:contents"));
assert_eq!(style.display, Display::Block);
assert!(style.display_contents);
}
#[test]
fn display_css_wide_values_replace_ua_and_prior_provenance() {
let initial = compute_style("td", Some("display:initial"));
assert_eq!(initial.display, Display::Inline);
assert!(!initial.internal_flex_container);
assert!(!initial.is_inline_block);
assert!(!initial.display_contents);
let unset = compute_style("div", Some("display:flex;display:unset"));
assert_eq!(unset.display, Display::Inline);
assert!(!unset.is_inline_block);
let inherited = compute_style("td", Some("display:contents;display:inherit"));
assert_eq!(inherited.display, Display::Inline);
assert!(inherited.display_inherit);
assert!(!inherited.internal_flex_container);
assert!(!inherited.display_contents);
let important = compute_style(
"div",
Some("display:block!important;display:contents"),
);
assert_eq!(important.display, Display::Block);
assert!(!important.display_contents);
}
#[test]
fn authored_display_replaces_internal_flex_provenance() {
let native_cell = compute_style("td", None);
assert_eq!(native_cell.display, Display::Flex);
assert!(native_cell.internal_flex_container);
let authored_cell = compute_style("td", Some("display:flex"));
assert_eq!(authored_cell.display, Display::Flex);
assert!(!authored_cell.internal_flex_container);
let invalid = compute_style("td", Some("display:bogus"));
assert!(invalid.internal_flex_container);
let native_image = compute_style("img", None);
assert_eq!(native_image.display, Display::Inline);
assert!(native_image.is_inline_block);
let block_image = compute_style("img", Some("display:block"));
assert_eq!(block_image.display, Display::Block);
assert!(!block_image.is_inline_block);
}
#[test]
fn table_ua_geometry_and_border_collapse_parse() {
let table = compute_style("table", None);
assert_eq!(table.box_sizing, crate::BoxSizing::BorderBox);
assert_eq!(table.border_spacing, Some((2.0, 2.0)));
assert_eq!(table.border_collapse, Some(false));
let cell = compute_style("td", None);
assert_eq!(
cell.padding,
Edges {
top: 1.0,
right: 1.0,
bottom: 1.0,
left: 1.0,
}
);
assert_eq!(cell.vertical_align, None);
let collapsed = compute_style(
"table",
Some("border-spacing:8px; border-collapse:collapse"),
);
assert_eq!(collapsed.border_spacing, Some((8.0, 8.0)));
assert_eq!(collapsed.border_collapse, Some(true));
}
#[test]
fn button_ua_style_is_a_centered_atomic_inline_box() {
let button = ua_style("button");
assert_eq!(button.display, crate::Display::Inline);
assert!(button.is_inline_block);
assert_eq!(button.text_align, Some(taffy::AlignItems::CENTER));
assert_eq!(button.box_sizing, crate::BoxSizing::BorderBox);
assert_eq!(
button.padding,
Edges {
top: 1.0,
right: 6.0,
bottom: 1.0,
left: 6.0,
}
);
}
#[test]
fn image_ua_style_does_not_invent_a_responsive_size_cap() {
let image = ua_style("img");
assert_eq!(image.display, crate::Display::Inline);
assert!(image.is_inline_block);
assert_eq!(image.max_width, crate::Dimension::Auto);
let authored = compute_style("img", Some("max-width:100%"));
assert_eq!(authored.max_width, crate::Dimension::Percent(1.0));
}
#[test]
fn border_none_clears_native_and_per_side_widths() {
let input = compute_style("input", Some("border:none"));
assert_eq!(input.border, Edges::default());
let side = compute_style(
"div",
Some("border:3px solid red;border-left:none"),
);
assert_eq!(side.border.top, 3.0);
assert_eq!(side.border.right, 3.0);
assert_eq!(side.border.bottom, 3.0);
assert_eq!(side.border.left, 0.0);
}
#[test]
fn uniform_border_radius_retains_percentage_until_box_resolution() {
let percentage = compute_style("div", Some("border-radius:50%"));
assert_eq!(percentage.border_radius.resolve(80.0, 40.0), (40.0, 20.0));
let pixels = compute_style("div", Some("border-radius:20px"));
assert_eq!(pixels.border_radius.resolve(80.0, 40.0), (20.0, 20.0));
let reset = compute_style("div", Some("border-radius:50%;border-radius:initial"));
assert!(reset.border_radius.is_zero());
}
#[test]
fn item_self_alignment_parses_and_resets() {
let aligned = compute_style(
"div",
Some("align-self:safe center;justify-self:flex-end"),
);
assert_eq!(aligned.align_self, Some(taffy::AlignSelf::SAFE_CENTER));
assert_eq!(aligned.justify_self, Some(taffy::JustifySelf::FLEX_END));
let reset = compute_style(
"div",
Some("align-self:center;align-self:auto;justify-self:end;justify-self:auto"),
);
assert_eq!(reset.align_self, None);
assert_eq!(reset.justify_self, None);
let normal = compute_style("div", Some("align-self:normal;justify-self:normal"));
assert_eq!(normal.align_self, Some(taffy::AlignSelf::STRETCH));
assert_eq!(normal.justify_self, Some(taffy::JustifySelf::STRETCH));
let shorthand = compute_style("div", Some("place-self:safe center flex-end"));
assert_eq!(shorthand.align_self, Some(taffy::AlignSelf::SAFE_CENTER));
assert_eq!(shorthand.justify_self, Some(taffy::JustifySelf::FLEX_END));
let parent = compute_style(
"div",
Some("align-items:start;justify-items:safe end;place-items:end center"),
);
assert_eq!(parent.align_items, Some(taffy::AlignItems::END));
assert_eq!(parent.justify_items, Some(taffy::JustifyItems::CENTER));
let content = compute_style(
"div",
Some("align-content:space-between;place-content:safe center end"),
);
assert_eq!(content.align_content, Some(taffy::AlignContent::SAFE_CENTER));
assert_eq!(content.justify_content, Some(taffy::JustifyContent::END));
}
#[test]
fn font_shorthand_expands_layout_fields_and_resets_omissions() {
let s = compute_style(
"div",
Some(
"font-style:italic;font-weight:bold;line-height:2;\
font:normal small-caps 500 64px/60px \"Google Sans\", sans-serif",
),
);
assert_eq!(s.font_size, Some(64.0));
assert_eq!(s.line_height, Some(crate::LineHeight::Px(60.0)));
assert_eq!(s.font_weight.as_deref(), Some("500"));
assert_eq!(s.font_style_italic, Some(false));
assert_eq!(s.font_family.as_deref(), Some("\"google sans\", sans-serif"));
let reset = compute_style(
"div",
Some("font-style:italic;font-weight:bold;line-height:2;font:20px Arial"),
);
assert_eq!(reset.font_size, Some(20.0));
assert_eq!(reset.line_height, Some(crate::LineHeight::Normal));
assert_eq!(reset.font_weight.as_deref(), Some("400"));
assert_eq!(reset.font_style_italic, Some(false));
}
#[test]
fn font_weight_preserves_numeric_values_and_resolves_relative_keywords() {
let medium = compute_style("div", Some("font-weight:500"));
assert_eq!(medium.font_weight.as_deref(), Some("500"));
let semibold = compute_style("div", Some("font-weight:600"));
assert_eq!(semibold.font_weight.as_deref(), Some("600"));
let normal = compute_style("strong", Some("font-weight:normal"));
assert_eq!(normal.font_weight.as_deref(), Some("400"));
assert_eq!(computed_font_weight(Some("bolder"), 99), 400);
assert_eq!(computed_font_weight(Some("bolder"), 349), 400);
assert_eq!(computed_font_weight(Some("bolder"), 350), 700);
assert_eq!(computed_font_weight(Some("bolder"), 550), 900);
assert_eq!(computed_font_weight(Some("bolder"), 900), 900);
assert_eq!(computed_font_weight(Some("lighter"), 99), 99);
assert_eq!(computed_font_weight(Some("lighter"), 100), 100);
assert_eq!(computed_font_weight(Some("lighter"), 350), 100);
assert_eq!(computed_font_weight(Some("lighter"), 550), 400);
assert_eq!(computed_font_weight(Some("lighter"), 750), 700);
assert_eq!(computed_font_weight(Some("lighter"), 900), 700);
}
#[test]
fn font_weight_css_wide_keywords_override_heading_ua_weight() {
let inherited = compute_style("h1", Some("font-weight:inherit"));
assert_eq!(inherited.font_weight.as_deref(), Some("inherit"));
assert_eq!(computed_font_weight(inherited.font_weight.as_deref(), 500), 500);
let unset = compute_style("h1", Some("font-weight:unset"));
assert_eq!(unset.font_weight.as_deref(), Some("inherit"));
assert_eq!(computed_font_weight(unset.font_weight.as_deref(), 500), 500);
let initial = compute_style("h1", Some("font-weight:initial"));
assert_eq!(initial.font_weight.as_deref(), Some("400"));
assert_eq!(computed_font_weight(initial.font_weight.as_deref(), 500), 400);
}
#[test]
fn variable_font_properties_parse_canonically_and_atomically() {
let style = compute_style(
"span",
Some(
r#"font-optical-sizing:none;
font-variation-settings:"wght" 500, "\6f psz" 14, "wght" 650"#,
),
);
assert_eq!(
style.font_optical_sizing,
Some(crate::FontOpticalSizing::None)
);
assert_eq!(
style.font_variation_settings.as_deref(),
Some(
[
crate::FontVariationSetting {
tag: *b"opsz",
value: 14.0,
},
crate::FontVariationSetting {
tag: *b"wght",
value: 650.0,
},
]
.as_slice()
)
);
let case_sensitive = compute_style(
"span",
Some(r#"font-variation-settings:"wght" 400, "WGHT" 700"#),
);
assert_eq!(
case_sensitive.font_variation_settings.as_deref(),
Some(
[
crate::FontVariationSetting {
tag: *b"WGHT",
value: 700.0,
},
crate::FontVariationSetting {
tag: *b"wght",
value: 400.0,
},
]
.as_slice()
)
);
for malformed in [
r#""abc" 1"#,
r#"wght 1"#,
r#""wght" 1,"#,
r#""wght" calc(1px)"#,
r#""wght" 1e999"#,
r#""wégt" 1"#,
] {
let css = format!(
r#"font-variation-settings:"opsz" 20;font-variation-settings:{malformed}"#
);
let unchanged = compute_style("span", Some(&css));
assert_eq!(
unchanged.font_variation_settings.as_deref(),
Some(
[crate::FontVariationSetting {
tag: *b"opsz",
value: 20.0,
}]
.as_slice()
),
"invalid declaration was not atomic: {malformed}"
);
}
}
#[test]
fn variable_font_css_wide_values_and_font_shorthand_obey_cascade() {
let inherited = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-optical-sizing:unset;
font-variation-settings:"wght" 700;font-variation-settings:inherit"#,
),
);
assert_eq!(inherited.font_optical_sizing, None);
assert_eq!(inherited.font_variation_settings, None);
let reset = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-optical-sizing:initial;
font-variation-settings:"wght" 700;font-variation-settings:normal"#,
),
);
assert_eq!(
reset.font_optical_sizing,
Some(crate::FontOpticalSizing::Auto)
);
assert_eq!(reset.font_variation_settings, Some(Vec::new()));
let reverted = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-optical-sizing:revert;
font-variation-settings:"wght" 700;font-variation-settings:revert-layer"#,
),
);
assert_eq!(reverted.font_optical_sizing, None);
assert_eq!(reverted.font_variation_settings, None);
let shorthand = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-variation-settings:"opsz" 22;
font:italic 500 20px/1.2 Inter"#,
),
);
assert_eq!(
shorthand.font_optical_sizing,
Some(crate::FontOpticalSizing::Auto)
);
assert_eq!(shorthand.font_variation_settings, Some(Vec::new()));
assert!(supports_declaration("font-optical-sizing", "auto"));
assert!(!supports_declaration("font-optical-sizing", "enabled"));
assert!(supports_declaration(
"font-variation-settings",
r#""opsz" 18, "wght" 500"#
));
assert!(supports_declaration(
"font-variation-settings",
r#""opsz" calc(18)"#
));
let calculated = compute_style(
"span",
Some(r#"font-variation-settings:"opsz" calc(10 * 2), "wght" max(400, 500)"#),
);
assert_eq!(
calculated.font_variation_settings.as_deref(),
Some(
[
crate::FontVariationSetting {
tag: *b"opsz",
value: 20.0,
},
crate::FontVariationSetting {
tag: *b"wght",
value: 500.0,
},
]
.as_slice()
)
);
assert!(!supports_declaration(
"font-variation-settings",
r#""opsz" calc(18px)"#
));
}
#[test]
fn letter_spacing_preserves_units_resets_and_invalid_cascade_values() {
let relative = compute_style("span", Some("letter-spacing:-.05em"));
assert_eq!(
relative.letter_spacing_raw,
Some(crate::Dimension::Em(-0.05))
);
assert_eq!(relative.letter_spacing_non_normal, Some(true));
let reset = compute_style(
"span",
Some("letter-spacing:4px;letter-spacing:normal"),
);
assert_eq!(reset.letter_spacing, Some(0.0));
assert_eq!(reset.letter_spacing_non_normal, Some(false));
let invalid = compute_style(
"span",
Some("letter-spacing:3px;letter-spacing:calc(10% + 1px)"),
);
assert_eq!(invalid.letter_spacing, Some(3.0));
assert_eq!(invalid.letter_spacing_non_normal, Some(true));
let inherited = compute_style("span", Some("letter-spacing:unset"));
assert_eq!(inherited.letter_spacing, None);
assert_eq!(inherited.letter_spacing_non_normal, None);
}
#[test]
fn text_indent_preserves_lengths_percentages_and_inherited_resets() {
let relative = compute_style("p", Some("text-indent:2em"));
assert_eq!(relative.text_indent, Some(crate::Dimension::Em(2.0)));
let percentage = compute_style("p", Some("text-indent:25%"));
assert_eq!(
percentage.text_indent,
Some(crate::Dimension::Percent(0.25))
);
let inherited = compute_style("p", Some("text-indent:18px;text-indent:unset"));
assert_eq!(inherited.text_indent, None);
let initial = compute_style("p", Some("text-indent:18px;text-indent:initial"));
assert_eq!(initial.text_indent, Some(crate::Dimension::Px(0.0)));
let invalid = compute_style(
"p",
Some("text-indent:18px;text-indent:10px hanging"),
);
assert_eq!(invalid.text_indent, Some(crate::Dimension::Px(18.0)));
assert!(supports_declaration("text-indent", "-9999px"));
assert!(supports_declaration("text-indent", "25%"));
assert!(!supports_declaration("text-indent", "10px hanging"));
}
#[test]
fn nonzero_unitless_font_size_is_invalid() {
let inherited = compute_style("div", Some("font-size:14px;font-size:.813"));
assert_eq!(inherited.font_size, Some(14.0));
let zero = compute_style("div", Some("font-size:0"));
assert_eq!(zero.font_size, Some(0.0));
}
#[test]
fn containing_block_property_triggers_are_independent() {
let s = compute_style("div", Some("transform:rotate(0deg);filter:none"));
assert!(s.establishes_positioning_containing_block());
let s = compute_style("div", Some("filter:blur(0);transform:none"));
assert!(s.establishes_positioning_containing_block());
let s = compute_style(
"div",
Some("contain:layout;content-visibility:visible;filter:none"),
);
assert!(s.establishes_positioning_containing_block());
let s = compute_style(
"div",
Some("contain:none;content-visibility:visible;filter:none;perspective:none"),
);
assert!(!s.establishes_positioning_containing_block());
}
#[test]
fn container_properties_and_shorthand_preserve_values() {
let defaults = compute_style("div", None);
assert_eq!(defaults.container_type, crate::ContainerType::Normal);
assert!(defaults.container_names.is_empty());
let longhands = compute_style(
"div",
Some("container-name:main sidebar;container-type:inline-size"),
);
assert_eq!(longhands.container_type, crate::ContainerType::InlineSize);
assert_eq!(longhands.container_names, ["main", "sidebar"]);
let shorthand = compute_style(
"div",
Some("container-name:old;container-type:size;container:main/inline-size"),
);
assert_eq!(shorthand.container_type, crate::ContainerType::InlineSize);
assert_eq!(shorthand.container_names, ["main"]);
let name_only = compute_style("div", Some("container-type:size;container:card"));
assert_eq!(name_only.container_type, crate::ContainerType::Normal);
let inherited = compute_style(
"div",
Some("container:outer/size;container:inherit"),
);
assert!(inherited.container_type_inherit);
assert!(inherited.container_names_inherit);
let reset = compute_style(
"div",
Some("container:outer/size;container:inherit;container:none"),
);
assert!(!reset.container_type_inherit);
assert!(!reset.container_names_inherit);
}
#[test]
fn container_supports_validation_is_typed_and_shorthand_atomic() {
let style = compute_style(
"div",
Some("container:main/inline-size;container:broken/unknown;container-name:also not"),
);
assert_eq!(style.container_type, crate::ContainerType::InlineSize);
assert_eq!(style.container_names, ["main"]);
assert!(!style.establishes_positioning_containing_block());
assert!(supports_declaration("container-type", "inline-size"));
assert!(!supports_declaration("container-type", "inline"));
assert!(supports_declaration("container-name", "main sidebar"));
assert!(!supports_declaration("container-name", "main not"));
assert!(supports_declaration("container", "main/inline-size"));
assert!(!supports_declaration("container", "main/unknown"));
}
#[test]
fn margin_shorthand_expands() {
let s = compute_style("div", Some("margin: 10px 20px"));
assert_eq!(s.margin, Edges { top: 10.0, right: 20.0, bottom: 10.0, left: 20.0 });
}
#[test]
fn border_width_accepts_css_math_tokens() {
let style = compute_style(
"button",
Some(
"border-style:solid;\
border-width:calc(1 * 1px);\
border-color:rgba(208,217,251,.4)",
),
);
assert_eq!(
style.border,
Edges {
top: 1.0,
right: 1.0,
bottom: 1.0,
left: 1.0,
}
);
assert_eq!(style.border_color, Some([208, 217, 251, 102]));
let asymmetric = compute_style(
"div",
Some("border-width:calc(1px * 2) 3px calc(2px + 2px) 5px"),
);
assert_eq!(
asymmetric.border,
Edges {
top: 2.0,
right: 3.0,
bottom: 4.0,
left: 5.0,
}
);
}
#[test]
fn longhand_overrides_shorthand() {
let s = compute_style("div", Some("padding: 5px; padding-left: 30px"));
assert_eq!(s.padding.top, 5.0);
assert_eq!(s.padding.left, 30.0);
}
#[test]
fn percentage_padding_recorded_not_pixelized() {
// A percentage padding must be deferred (recorded as a 0..1 fraction),
// not eagerly converted to a bogus px value: it resolves against the
// containing block width later, in the top-down pass.
let s = compute_style("div", Some("padding-top: 56.25%"));
assert_eq!(s.padding_percent[0], Some(0.5625));
assert_eq!(s.padding.top, 0.0);
// The shorthand splits per side, so a mix of length and percent lands
// in the right buckets.
let s = compute_style("div", Some("padding: 10px 25%"));
assert_eq!(s.padding.top, 10.0);
assert_eq!(s.padding_percent[0], None);
assert_eq!(s.padding_percent[1], Some(0.25));
}
#[test]
fn percentage_margin_recorded() {
let s = compute_style("div", Some("margin-left: 10%"));
assert_eq!(s.margin_percent[3], Some(0.1));
assert!(!s.margin_auto[3]);
}
#[test]
fn relative_box_edges_remain_unresolved() {
let s = compute_style(
"div",
Some("font-size:20px;margin:15vh auto 2em 10vw;padding:1rem 2vmin"),
);
assert_eq!(s.margin_relative[0], Some(crate::Dimension::Vh(15.0)));
assert!(s.margin_auto[1]);
assert_eq!(s.margin_relative[2], Some(crate::Dimension::Em(2.0)));
assert_eq!(s.margin_relative[3], Some(crate::Dimension::Vw(10.0)));
assert_eq!(s.padding_relative[0], Some(crate::Dimension::Rem(1.0)));
assert_eq!(s.padding_relative[1], Some(crate::Dimension::Vmin(2.0)));
}
#[test]
fn logical_insets_map_to_ltr_physical_edges() {
let style = compute_style(
"div",
Some(
"inset-inline:10px 20%;\
inset-block:1rem calc(100vh - 2px);\
inset-inline-start:3px",
),
);
assert_eq!(style.inset[3], Some(crate::Dimension::Px(3.0)));
assert_eq!(style.inset[1], Some(crate::Dimension::Percent(0.2)));
assert_eq!(style.inset[0], Some(crate::Dimension::Rem(1.0)));
assert!(style.inset[2].is_none());
assert_eq!(
style.inset_expressions[2].as_deref(),
Some("calc(100vh - 2px)")
);
assert!(supports_declaration("inset-inline", "0"));
assert!(supports_declaration("inset-block-end", "2rem"));
}
#[test]
fn counter_properties_parse_ordered_name_integer_pairs() {
let style = compute_style(
"div",
Some(
"counter-reset:chapter 2 line;\
counter-increment:chapter line 3;\
counter-set:folio 9",
),
);
let pairs = |pairs: &[crate::CounterDirective]| {
pairs
.iter()
.map(|pair| (pair.name.clone(), pair.value))
.collect::<Vec<_>>()
};
assert_eq!(
pairs(&style.counter_reset),
[("chapter".to_string(), 2), ("line".to_string(), 0)]
);
assert_eq!(
pairs(&style.counter_increment),
[("chapter".to_string(), 1), ("line".to_string(), 3)]
);
assert_eq!(pairs(&style.counter_set), [("folio".to_string(), 9)]);
assert!(supports_declaration("counter-reset", "line"));
assert!(supports_declaration("counter-increment", "line 2"));
let cleared = compute_style("div", Some("counter-reset:line 4;counter-reset:none"));
assert!(cleared.counter_reset.is_empty());
}
#[test]
fn responsive_grid_repetitions_and_shorthand_remain_typed() {
let auto = compute_style(
"div",
Some("display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr))"),
);
assert!(matches!(
auto.grid_template_columns.as_slice(),
[taffy::GridTemplateComponent::Repeat(
taffy::GridTemplateRepetition {
count: taffy::RepetitionCount::AutoFit,
..
}
)]
));
let shorthand = compute_style(
"div",
Some("display:grid;grid:auto-flow/repeat(3,1fr)"),
);
assert_eq!(shorthand.grid_auto_flow, Some(taffy::GridAutoFlow::Row));
assert_eq!(shorthand.grid_template_columns.len(), 3);
}
#[test]
fn grid_placement_longhands_preserve_the_opposite_side() {
let style = compute_style(
"div",
Some(
"grid-column-start:2;grid-column-end:span 4;\
grid-row-start:3;grid-row-end:5",
),
);
assert_eq!(
style.grid_column,
Some(taffy::Line {
start: taffy::style_helpers::line(2),
end: taffy::style_helpers::span(4),
})
);
assert_eq!(
style.grid_row,
Some(taffy::Line {
start: taffy::style_helpers::line(3),
end: taffy::style_helpers::line(5),
})
);
}
#[test]
fn grid_placement_shorthand_keeps_auto_span_out_of_named_line_resolution() {
let style = compute_style("div", Some("grid-column:auto / span 4"));
assert_eq!(
style.grid_column,
Some(taffy::Line {
start: taffy::GridPlacement::Auto,
end: taffy::style_helpers::span(4),
})
);
assert_eq!(style.grid_column_raw, None);
let named = compute_style("div", Some("grid-column:content-start / content-end"));
assert_eq!(
named.grid_column_raw.as_deref(),
Some("content-start / content-end")
);
assert_eq!(named.grid_column, None);
let reset = compute_style(
"div",
Some("grid-column:2 / span 4;grid-column:initial"),
);
assert_eq!(
reset.grid_column,
Some(taffy::Line {
start: taffy::GridPlacement::Auto,
end: taffy::GridPlacement::Auto,
})
);
assert_eq!(reset.grid_column_raw, None);
}
#[test]
fn grid_area_expands_slash_placements_and_named_area_defaults() {
let overlay = compute_style("div", Some("grid-area:1 / 2 / 3 / 4"));
assert_eq!(
overlay.grid_row,
Some(taffy::Line {
start: taffy::style_helpers::line(1),
end: taffy::style_helpers::line(3),
})
);
assert_eq!(
overlay.grid_column,
Some(taffy::Line {
start: taffy::style_helpers::line(2),
end: taffy::style_helpers::line(4),
})
);
assert_eq!(overlay.grid_area_name, None);
let named = compute_style("div", Some("grid-area:hero"));
assert_eq!(named.grid_area_name.as_deref(), Some("hero"));
assert_eq!(named.grid_row_raw.as_deref(), Some("hero / hero"));
assert_eq!(named.grid_column_raw.as_deref(), Some("hero / hero"));
let reset = compute_style("div", Some("grid-area:hero;grid-area:initial"));
assert_eq!(reset.grid_area_name, None);
assert_eq!(
reset.grid_row,
Some(taffy::Line {
start: taffy::GridPlacement::Auto,
end: taffy::GridPlacement::Auto,
})
);
assert_eq!(
reset.grid_column,
Some(taffy::Line {
start: taffy::GridPlacement::Auto,
end: taffy::GridPlacement::Auto,
})
);
for value in ["2 foo", "foo 2", "calc(1)"] {
let line = compute_style("div", Some(&format!("grid-area:{value}")));
let expected_row = format!("{value} / auto");
assert_eq!(
line.grid_area_name, None,
"{value} is a grid line, not an ident-only named area"
);
assert_eq!(
line.grid_row_raw.as_deref(),
Some(expected_row.as_str())
);
assert_eq!(
line.grid_column,
Some(taffy::Line {
start: taffy::GridPlacement::Auto,
end: taffy::GridPlacement::Auto,
})
);
}
let one = compute_style("div", Some("grid-area:1"));
assert_eq!(
one.grid_row,
Some(taffy::Line {
start: taffy::style_helpers::line(1),
end: taffy::GridPlacement::Auto,
})
);
assert_eq!(
one.grid_column,
Some(taffy::Line {
start: taffy::GridPlacement::Auto,
end: taffy::GridPlacement::Auto,
})
);
let mixed = compute_style("div", Some("grid-area:hero / 2"));
assert_eq!(mixed.grid_row_raw.as_deref(), Some("hero / hero"));
assert_eq!(
mixed.grid_column,
Some(taffy::Line {
start: taffy::style_helpers::line(2),
end: taffy::GridPlacement::Auto,
})
);
let copied_column_ident = compute_style("div", Some("grid-area:1 / col / 3"));
assert_eq!(
copied_column_ident.grid_row,
Some(taffy::Line {
start: taffy::style_helpers::line(1),
end: taffy::style_helpers::line(3),
})
);
assert_eq!(
copied_column_ident.grid_column_raw.as_deref(),
Some("col / col")
);
let uppercase_span = compute_style("div", Some("grid-area:SPAN 2"));
assert_eq!(
uppercase_span.grid_row,
Some(taffy::Line {
start: taffy::style_helpers::span(2),
end: taffy::GridPlacement::Auto,
})
);
for invalid in ["0", "span", "span 0", "span -1", "foo bar", "1 / inherit", "1.5"] {
let style = compute_style(
"div",
Some(&format!("grid-area:7 / 8 / 9 / 10;grid-area:{invalid}")),
);
assert_eq!(
style.grid_row,
Some(taffy::Line {
start: taffy::style_helpers::line(7),
end: taffy::style_helpers::line(9),
}),
"invalid `{invalid}` must not partially mutate the earlier shorthand"
);
assert_eq!(
style.grid_column,
Some(taffy::Line {
start: taffy::style_helpers::line(8),
end: taffy::style_helpers::line(10),
}),
"invalid `{invalid}` must not partially mutate the earlier shorthand"
);
assert!(!supports_declaration("grid-area", invalid));
}
for valid in ["hero", "1", "hero / 2", "1 / col / 3", "SPAN 2", "span foo"] {
assert!(
supports_declaration("grid-area", valid),
"valid grid-area `{valid}` should pass @supports parsing"
);
}
}
#[test]
fn contextual_css_math_uses_runtime_geometry() {
let context = (20.0, 16.0, 9.0, 10.0, 900.0);
assert_eq!(
resolve_contextual_length(
"min(25vw,350px)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(225.0)
);
assert_eq!(
resolve_contextual_length(
"clamp(200px,30vw,320px)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(270.0)
);
assert_eq!(
resolve_contextual_length(
"calc(10vw + 2rem)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(122.0)
);
assert_eq!(
resolve_contextual_length(
"calc(35rem*-1/4)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(-140.0)
);
assert_eq!(
resolve_contextual_length(
"calc(round(247px * 1, 10px))",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(250.0)
);
let grouped = resolve_contextual_length(
"calc(clamp(128px,92px + 7vw,188px) + (100vw - 48px)*43/440)",
context.0,
context.1,
context.2,
context.3,
context.4,
)
.unwrap();
assert!(
(grouped - 238.263_64).abs() < 0.001,
"grouped calc should preserve the nested subtraction: {grouped}"
);
}
#[test]
fn ua_defaults_and_ignore_unknown() {
let s = compute_style("span", Some("color: red; ; bogus: ; display: none"));
assert_eq!(s.display, Display::None);
}
#[test]
fn background_clip_text_flag() {
let s = compute_style("h1", Some("color: transparent; -webkit-background-clip: text"));
assert!(s.background_clip_text);
let vendor_fill = compute_style(
"h1",
Some(
"color: black; -webkit-text-fill-color: transparent;\
background: linear-gradient(90deg, red, blue);\
-webkit-background-clip: text",
),
);
assert_eq!(vendor_fill.color, Some([0, 0, 0, 0]));
assert!(vendor_fill.background_clip_text);
assert!(vendor_fill.background_gradient.is_some());
let legacy_angle = compute_style(
"span",
Some("background:-webkit-linear-gradient(315deg,#42d392 25%,#647eff)"),
)
.background_gradient
.expect("prefixed gradient")
.0;
assert_eq!(legacy_angle, 135.0);
let n = compute_style("h1", Some("background-clip: border-box"));
assert!(!n.background_clip_text);
let l = compute_style("h1", Some("background-clip: text"));
assert!(l.background_clip_text);
}
#[test]
fn light_dark_uses_light_scheme_and_validates_both_nested_branches() {
assert_eq!(
parse_color("light-dark(rgb(16, 32, 48), hsl(0 100% 50%))"),
Some([16, 32, 48, 255])
);
assert_eq!(
parse_color(
"LIGHT-DARK(color-mix(in srgb, #204060 75%, transparent), \
light-dark(white, black))"
),
Some([32, 64, 96, 191]),
"nested commas must stay inside their color function"
);
let style = compute_style(
"div",
Some(
"color:light-dark(#123456,#ffffff);\
background-color:light-dark(rgb(1, 2, 3),rgb(4, 5, 6));\
border-color:light-dark(red,blue)",
),
);
assert_eq!(style.color, Some([0x12, 0x34, 0x56, 255]));
assert_eq!(style.background_color, Some([1, 2, 3, 255]));
assert_eq!(style.border_color, Some([255, 0, 0, 255]));
assert!(supports_declaration(
"color",
"light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"
));
for malformed in [
"light-dark(red)",
"light-dark(red, blue, green)",
"light-dark(red,)",
"light-dark(,blue)",
"light-dark(red, rgb(1, 2, 3)",
"light-dark(red garbage, blue)",
"light-dark(red, definitely-not-a-color)",
"light-dark(red, blue) trailing",
] {
assert_eq!(
parse_color(malformed),
None,
"malformed light-dark() must invalidate the declaration: {malformed}"
);
assert!(
!supports_declaration("color", malformed),
"@supports must reject malformed light-dark(): {malformed}"
);
}
}
#[test]
fn background_shorthand_resets_omitted_layers() {
let s = compute_style(
"div",
Some(
"background:#1971c2 url(icon.png);background-size:20px 20px;\
background-position:center;background-clip:text;background:0",
),
);
assert_eq!(s.background_color, None);
assert_eq!(s.background_gradient, None);
assert_eq!(s.background_conic_gradient, None);
assert!(s.background_gradient_layers.is_empty());
assert_eq!(s.background_image, None);
assert_eq!(s.background_size, None);
assert_eq!(s.background_size_expression, None);
assert_eq!(s.background_size_fit, None);
assert_eq!(s.background_position, crate::BackgroundPosition::default());
assert!(!s.background_clip_text);
let cover = compute_style(
"div",
Some("background:url(hero.svg) center/cover no-repeat"),
);
assert_eq!(cover.background_size_fit, Some(crate::ObjectFit::Cover));
let contain = compute_style("div", Some("background-size:contain"));
assert_eq!(
contain.background_size_fit,
Some(crate::ObjectFit::Contain)
);
let contextual = compute_style(
"a",
Some(
"background:url(icon.svg) no-repeat 0 50% / calc(100% - 2rem) auto",
),
);
assert_eq!(
contextual.background_size_expression.as_deref(),
Some("calc(100% - 2rem) auto")
);
}
#[test]
fn background_gradients_keep_authored_layer_order_and_keyword_centers() {
let style = compute_style(
"div",
Some(
"background-image:\
linear-gradient(180deg,transparent,white 85%),\
radial-gradient(ellipse at top left,red,transparent 50%),\
radial-gradient(ellipse at top right,blue,transparent 50%),\
radial-gradient(ellipse at center right,lime,transparent 50%),\
radial-gradient(ellipse at center left,fuchsia,transparent 50%)",
),
);
assert_eq!(style.background_gradient_layers.len(), 5);
assert!(matches!(
style.background_gradient_layers[0],
crate::BackgroundGradientLayer::Linear { angle, .. }
if (angle - 180.0).abs() < 0.001
));
let centers: Vec<(f32, f32)> = style
.background_gradient_layers
.iter()
.filter_map(|layer| match layer {
crate::BackgroundGradientLayer::Radial { center, .. } => Some(*center),
_ => None,
})
.collect();
assert_eq!(
centers,
vec![(0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)]
);
}
#[test]
fn repeating_linear_gradient_retains_length_stops_and_repeat_axes() {
let style = compute_style(
"div",
Some(
"background-image:repeating-linear-gradient(315deg,\
rgba(0,0,0,.05) 0,rgba(0,0,0,.05) 1px,\
transparent 0,transparent 50%);\
background-size:10px 10px;background-repeat:repeat-x no-repeat",
),
);
assert_eq!(style.background_repeat, Some((true, false)));
let crate::BackgroundGradientLayer::Linear {
stop_positions,
repeating,
..
} = &style.background_gradient_layers[0]
else {
panic!("expected a linear gradient layer");
};
assert!(*repeating);
assert_eq!(
stop_positions,
&[
Some("0".to_string()),
Some("1px".to_string()),
Some("0".to_string()),
Some("50%".to_string()),
]
);
}
#[test]
fn background_position_preserves_sprite_offsets() {
use crate::{BackgroundPosition, BackgroundPositionAxis};
let first = compute_style("div", Some("background-position:0"));
assert_eq!(
first.background_position,
BackgroundPosition::new(
BackgroundPositionAxis::pixels(0.0),
BackgroundPositionAxis::percentage(0.5),
)
);
let selected = compute_style("div", Some("background-position:-24px"));
assert_eq!(
selected.background_position,
BackgroundPosition::new(
BackgroundPositionAxis::pixels(-24.0),
BackgroundPositionAxis::percentage(0.5),
)
);
let edge_offsets = compute_style("div", Some("background-position:right 10px bottom 20px"));
assert_eq!(
edge_offsets.background_position,
BackgroundPosition::new(
BackgroundPositionAxis::length_percentage(-10.0, 1.0),
BackgroundPositionAxis::length_percentage(-20.0, 1.0),
)
);
}
#[test]
fn conic_background_and_repeated_data_svg_mask_are_preserved() {
let style = compute_style(
"div",
Some(
"background:conic-gradient(from 122deg at 50% 50%,\
transparent 17%,#f627e3 25%,#6911d2 32%,transparent 91%);\
mask-image:url(\"data:image/svg+xml,<svg viewBox='0 0 72 72'>\
<g transform='translate(36 36) rotate(-60)'></g></svg>\");\
mask-size:22px 22px;mask-repeat:repeat",
),
);
let (angle, center, stops) = style
.background_conic_gradient
.expect("conic gradient should parse");
assert_eq!(angle, 122.0);
assert_eq!(center, (0.5, 0.5));
assert_eq!(stops.len(), 4);
assert_eq!(style.mask_size, Some((22.0, 22.0)));
assert_eq!(style.mask_repeat, Some((true, true)));
let mask = style.mask_image.expect("data SVG mask should parse");
assert!(mask.ends_with("</svg>"));
assert!(mask.contains("rotate(-60)"));
}
#[test]
fn important_and_auto() {
let s = compute_style("div", Some("width: 100px !important; height: auto"));
assert_eq!(s.width, crate::Dimension::Px(100.0));
assert_eq!(s.height, crate::Dimension::Auto);
}
#[test]
fn overflow_axis_coupling_recomputes_from_winning_specified_values() {
let style = compute_style(
"div",
Some(
"overflow-x:hidden;overflow-y:auto;\
overflow-y:visible;overflow-x:visible",
),
);
assert!(!style.overflow_clip_x);
assert!(!style.overflow_clip_y);
assert!(!style.overflow_hidden);
assert!(!style.overflow_scroll_container);
let style = compute_style("div", Some("overflow-x:clip;overflow-y:visible"));
assert!(style.overflow_clip_x);
assert!(!style.overflow_clip_y);
assert!(!style.overflow_scroll_container);
}
#[test]
fn overflow_rejects_invalid_values_and_handles_css_wide_keywords_atomically() {
let style = compute_style(
"div",
Some(
"overflow-x:clip;overflow-x:nonsense;\
overflow:hidden;overflow:hidden visible auto",
),
);
assert!(style.overflow_scroll_x && style.overflow_scroll_y);
let reset = compute_style("div", Some("overflow:hidden;overflow:initial"));
assert!(!reset.overflow_hidden);
assert!(!reset.overflow_scroll_container);
let longhand_reset =
compute_style("div", Some("overflow:hidden;overflow-x:initial"));
assert!(
longhand_reset.overflow_scroll_x && longhand_reset.overflow_scroll_y,
"visible/hidden must recompute to auto/hidden"
);
for valid in [
("overflow", "hidden"),
("overflow", "clip visible"),
("overflow", "initial"),
("overflow-x", "inherit"),
] {
assert!(supports_declaration(valid.0, valid.1), "{valid:?}");
}
for invalid in [
("overflow", ""),
("overflow", "nonsense"),
("overflow", "hidden visible auto"),
("overflow", "inherit visible"),
("overflow-y", "hidden auto"),
] {
assert!(!supports_declaration(invalid.0, invalid.1), "{invalid:?}");
}
}
#[test]
fn box_sizing_parses_values_and_css_wide_keywords() {
assert_eq!(
compute_style("div", None).box_sizing,
crate::BoxSizing::ContentBox
);
assert_eq!(
compute_style("div", Some("box-sizing:border-box")).box_sizing,
crate::BoxSizing::BorderBox
);
assert_eq!(
compute_style(
"div",
Some("box-sizing:border-box;box-sizing:content-box")
)
.box_sizing,
crate::BoxSizing::ContentBox
);
assert_eq!(
compute_style("div", Some("box-sizing:inherit")).box_sizing,
crate::BoxSizing::Inherit
);
for keyword in ["initial", "unset", "revert", "revert-layer"] {
assert_eq!(
compute_style(
"div",
Some(&format!("box-sizing:border-box;box-sizing:{keyword}"))
)
.box_sizing,
crate::BoxSizing::ContentBox,
"{keyword} must not retain the preceding border-box value"
);
}
}
#[test]
fn box_sizing_initial_restores_content_box_geometry_after_universal_reset() {
use obscura_dom::tree_sink::parse_html;
let tree = parse_html(
r#"<style>
* { box-sizing: border-box }
body { margin: 0 }
.parent { width: 600px }
.form {
box-sizing: initial;
width: 100%;
max-width: 435px;
padding: 15px;
}
</style>
<div class="parent"><form id="form" class="form"></form></div>"#,
);
let laid = crate::dom::layout_dom(&tree, (1280.0, 720.0));
let form = tree.query_selector("#form").unwrap().unwrap();
assert_eq!(laid.styles[&form].box_sizing, crate::BoxSizing::ContentBox);
assert_eq!(
laid.rects[&form].width, 465.0,
"435px content max-width plus 15px padding on both sides"
);
}
#[test]
fn calc_with_multiply_and_divide() {
// The exact shape MediaWiki uses to offset a TOC toggle button into
// the left margin: a negative product divided by a constant.
assert_eq!(resolve_length("calc(-1 * 22px / 2)"), Some(-11.0));
// Minifiers commonly remove every optional space. A sign immediately
// following `*` or `/` is unary and belongs to that factor.
assert_eq!(resolve_length("calc(35px*-1/4)"), Some(-8.75));
}
#[test]
fn calc_add_and_subtract() {
assert_eq!(resolve_length("calc(750px - 1px)"), Some(749.0));
assert_eq!(resolve_length("calc(10px + 5px)"), Some(15.0));
}
#[test]
fn var_with_fallback_resolves_to_fallback() {
assert_eq!(resolve_length("var(--font-size-medium, 1rem)"), Some(16.0));
}
#[test]
fn var_without_fallback_is_unresolvable() {
assert_eq!(resolve_length("var(--unknown-token)"), None);
}
#[test]
fn min_and_max_functions() {
assert_eq!(resolve_length("max(5px, 10px)"), Some(10.0));
assert_eq!(resolve_length("min(5px, 10px)"), Some(5.0));
}
#[test]
fn nested_var_calc_max_like_wikipedia_icon_sizing() {
// calc(max(calc(var(--font-size-medium,1rem) + 4px),10px))
let expr = "calc(max(calc(var(--font-size-medium,1rem) + 4px),10px))";
assert_eq!(resolve_length(expr), Some(20.0));
}
#[test]
fn width_property_resolves_calc_with_var() {
let s = compute_style("div", Some("width: calc(var(--x, 10px) + 5px)"));
assert_eq!(s.width, crate::Dimension::Px(15.0));
}
#[test]
fn flex_shorthand_two_numbers() {
// The exact form Wikipedia's infobox uses for its label/value cells
// (`.infobox tbody > tr > th/td{flex:1 0}`): without shorthand
// support this was silently dropped, leaving both columns
// shrink-to-fit instead of sharing the row's width.
let s = compute_style("div", Some("flex: 1 0"));
assert_eq!(s.flex_grow, Some(1.0));
assert_eq!(s.flex_shrink, Some(0.0));
}
#[test]
fn flex_shorthand_keywords() {
let none = compute_style("div", Some("flex: none"));
assert_eq!(none.flex_grow, Some(0.0));
assert_eq!(none.flex_shrink, Some(0.0));
let auto = compute_style("div", Some("flex: auto"));
assert_eq!(auto.flex_grow, Some(1.0));
assert_eq!(auto.flex_shrink, Some(1.0));
}
#[test]
fn flex_shorthand_single_number_defaults_shrink_to_one() {
let s = compute_style("div", Some("flex: 2"));
assert_eq!(s.flex_grow, Some(2.0));
assert_eq!(s.flex_shrink, Some(1.0));
}
#[test]
fn box_shadow_outset_parses() {
let s = compute_style("div", Some("box-shadow: 0 2px 8px rgba(0,0,0,.15)"));
let sh = s.box_shadow.expect("box-shadow parsed");
assert!(!sh.inset);
assert_eq!(sh.offset_x, 0.0);
assert_eq!(sh.offset_y, 2.0);
assert_eq!(sh.blur, 8.0);
assert_eq!(sh.spread, 0.0);
assert_eq!(sh.color, [0, 0, 0, 38]);
}
#[test]
fn box_shadow_inset_parses() {
let s = compute_style("div", Some("box-shadow: inset 0 0 0 1px #ccc"));
let sh = s.box_shadow.expect("box-shadow parsed");
assert!(sh.inset);
assert_eq!(sh.offset_x, 0.0);
assert_eq!(sh.offset_y, 0.0);
assert_eq!(sh.blur, 0.0);
assert_eq!(sh.spread, 1.0);
assert_eq!(sh.color, [204, 204, 204, 255]);
}
#[test]
fn box_shadow_color_defaults_to_current_color() {
// No explicit color: falls back to the element's text color.
let s = compute_style("div", Some("color: red; box-shadow: 1px 1px 2px"));
let sh = s.box_shadow.expect("box-shadow parsed");
assert_eq!(sh.color, [255, 0, 0, 255]);
}
#[test]
fn box_shadow_none_clears() {
let s = compute_style("div", Some("box-shadow: none"));
assert!(s.box_shadow.is_none());
}
}