diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 453963d..7fd8a5d 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -359,16 +359,17 @@ impl<'a> ContainerQueryEvaluator<'a> { if !axis_available { return (ContainerQueryTruth::Unknown, Some(id)); } - let truth = query.condition.as_ref().map_or( - ContainerQueryTruth::True, - |condition| { + let truth = + query + .condition + .as_ref() + .map_or(ContainerQueryTruth::True, |condition| { evaluate_container_query_expr( condition, container, self.snapshot.root_font_size, ) - }, - ); + }); return (truth, Some(id)); } } @@ -745,7 +746,7 @@ impl Stylesheet { }; style.color_scheme_dark = host_style.color_scheme_dark; let inherited_color_scheme_dark = host_style.color_scheme_dark; - let mut content = None; + let mut generated_content = None; for &(_, _, rule) in &matched { let expanded = substitute_declarations(&rule.normal_decls, props); crate::style::apply_color_scheme_declarations_from( @@ -769,7 +770,7 @@ impl Stylesheet { &expanded, ); if let Some(value) = extract_content(&expanded, tree, nid) { - content = value; + generated_content = value; } } for &(_, _, rule) in &matched { @@ -779,11 +780,14 @@ impl Stylesheet { &expanded, ); if let Some(value) = extract_content(&expanded, tree, nid) { - content = value; + generated_content = value; } } - style.before_content = content; - if style.before_content.is_some() || style.content_image.is_some() { + style.before_content = generated_content + .as_ref() + .map(|items| generated_content_with_zero_counters(items)); + style.generated_content = generated_content; + if style.generated_content.is_some() || style.content_image.is_some() { Some(style) } else { None @@ -1461,13 +1465,53 @@ fn substitute_var_value(input: &str, props: &HashMap, depth: u8) substitute_var_value(fallback, props, depth + 1)? } }; + // `var()` substitutes a token sequence, not source text. Insert a + // separator only where reparsing would merge boundary tokens: + // `2px` + `solid` must not become the dimension `2pxsolid`, and `10` + // + `%` must not become a percentage. Do not add unconditional + // whitespace: it is significant around calc()'s `+` and `-`. + if out + .chars() + .next_back() + .zip(replacement.chars().next()) + .is_some_and(|(left, right)| css_substitution_boundary_merges(left, right)) + { + out.push(' '); + } out.push_str(&replacement); + if replacement + .chars() + .next_back() + .zip(after[end + 1..].chars().next()) + .is_some_and(|(left, right)| css_substitution_boundary_merges(left, right)) + { + out.push(' '); + } rest = &after[end + 1..]; } out.push_str(rest); Some(out) } +fn css_substitution_boundary_merges(left: char, right: char) -> bool { + let name = |ch: char| { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '\\' || !ch.is_ascii() + }; + if left.is_ascii_digit() && right == '-' { + // A number followed by `-` remains two tokens. Separating these would + // incorrectly make `calc(var(--n)- 1px)` satisfy calc's whitespace + // requirement. + return false; + } + (name(left) && name(right)) + || ((left.is_ascii_digit() || left == '.') + && (right == '.' || right == '%' || name(right))) + || (matches!(left, '#' | '@') && name(right)) + || (!left.is_ascii_digit() && name(left) && right == '(') + || (left == '+' && (right.is_ascii_digit() || right == '.')) + || (left == '/' && right == '*') +} + /// If `selector`'s rightmost compound is the pseudo-element `which` /// (`"before"` or `"after"`, matching either the modern `::` or legacy `:` /// form), return everything before it, trimmed. Only a trailing @@ -1493,7 +1537,11 @@ fn strip_pseudo_element<'a>(selector: &'a str, which: &str) -> Option<&'a str> { /// strings, support the common `attr(name)` form used by component-library /// buttons and badges. The attribute is resolved against the originating /// element, as CSS generated content requires. -fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option> { +fn extract_content( + decls: &str, + tree: &DomTree, + nid: NodeId, +) -> Option>> { let mut result = None; for raw in crate::style::split_declarations(decls) { let Some((name, value)) = raw.split_once(':') else { continue }; @@ -1505,22 +1553,7 @@ fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option Option String { + let mut text = String::new(); + for item in items { + match item { + crate::GeneratedContentItem::Text(value) => text.push_str(value), + crate::GeneratedContentItem::Counter { style, .. } => { + text.push_str(&format_counter_value(0, *style)); + } + crate::GeneratedContentItem::Counters { + style, + separator: _, + .. + } => text.push_str(&format_counter_value(0, *style)), + } + } + text +} + +fn parse_generated_content_items( + value: &str, + tree: &DomTree, + nid: NodeId, +) -> Option> { + let mut items = Vec::new(); + let mut rest = value.trim(); + while !rest.is_empty() { + rest = rest.trim_start(); + let first = rest.chars().next()?; + if matches!(first, '"' | '\'') { + let (raw, tail) = take_css_quoted(rest)?; + items.push(crate::GeneratedContentItem::Text(unescape_css_string(raw))); + rest = tail; + continue; + } + + let name_end = rest + .char_indices() + .find_map(|(index, ch)| (!is_css_ident_char(ch)).then_some(index)) + .unwrap_or(rest.len()); + let name = &rest[..name_end]; + let after_name = rest[name_end..].trim_start(); + if !after_name.starts_with('(') { + // Quote-control keywords are valid generated-content items, but + // they do not contribute text in the current renderer. + if matches!( + name.to_ascii_lowercase().as_str(), + "open-quote" | "close-quote" | "no-open-quote" | "no-close-quote" + ) { + rest = after_name; + continue; + } + return None; + } + let (arguments, tail) = take_css_function_arguments(after_name)?; + match name.to_ascii_lowercase().as_str() { + "attr" => { + let attribute = arguments + .split_whitespace() + .next() + .filter(|name| !name.is_empty())?; + let value = tree + .get_node(nid) + .and_then(|node| node.get_attribute(attribute).map(str::to_owned)) + .unwrap_or_default(); + items.push(crate::GeneratedContentItem::Text(value)); + } + "counter" => { + let arguments = split_function_arguments(arguments); + let name = arguments.first()?.trim(); + if !valid_generated_counter_name(name) || arguments.len() > 2 { + return None; + } + let style = match arguments.get(1) { + Some(style) => parse_generated_counter_style(style.trim())?, + None => crate::GeneratedCounterStyle::default(), + }; + items.push(crate::GeneratedContentItem::Counter { + name: name.to_string(), + style, + }); + } + "counters" => { + let arguments = split_function_arguments(arguments); + if !(2..=3).contains(&arguments.len()) { + return None; + } + let name = arguments[0].trim(); + if !valid_generated_counter_name(name) { + return None; + } + let (separator, separator_tail) = take_css_quoted(arguments[1].trim())?; + if !separator_tail.trim().is_empty() { + return None; + } + let style = match arguments.get(2) { + Some(style) => parse_generated_counter_style(style.trim())?, + None => crate::GeneratedCounterStyle::default(), + }; + items.push(crate::GeneratedContentItem::Counters { + name: name.to_string(), + separator: unescape_css_string(separator), + style, + }); + } + _ => return None, + } + rest = tail; + } + (!items.is_empty()).then_some(items) +} + +fn is_css_ident_char(ch: char) -> bool { + ch.is_alphanumeric() || matches!(ch, '-' | '_' | '\\') +} + +fn take_css_quoted(value: &str) -> Option<(&str, &str)> { + let quote = value.chars().next().filter(|ch| matches!(ch, '"' | '\''))?; + let mut escaped = false; + for (offset, ch) in value[quote.len_utf8()..].char_indices() { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == quote { + let end = quote.len_utf8() + offset; + return Some(( + &value[quote.len_utf8()..end], + &value[end + quote.len_utf8()..], + )); + } + } + None +} + +fn take_css_function_arguments(value: &str) -> Option<(&str, &str)> { + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (offset, ch) in value.char_indices() { + if let Some(open) = quote { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == open { + quote = None; + } + continue; + } + match ch { + '"' | '\'' => quote = Some(ch), + '(' => depth += 1, + ')' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some((&value[1..offset], &value[offset + 1..])); + } + } + _ => {} + } + } + None +} + +fn split_function_arguments(value: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut start = 0; + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (offset, ch) in value.char_indices() { + if let Some(open) = quote { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == open { + quote = None; + } + continue; + } + match ch { + '"' | '\'' => quote = Some(ch), + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + result.push(value[start..offset].trim()); + start = offset + 1; + } + _ => {} + } + } + result.push(value[start..].trim()); + result +} + +fn valid_generated_counter_name(name: &str) -> bool { + !name.is_empty() && name.chars().all(is_css_ident_char) +} + +fn parse_generated_counter_style(value: &str) -> Option { + Some(match value.to_ascii_lowercase().as_str() { + "decimal" => crate::GeneratedCounterStyle::Decimal, + "decimal-leading-zero" => crate::GeneratedCounterStyle::DecimalLeadingZero, + "lower-alpha" | "lower-latin" => crate::GeneratedCounterStyle::LowerAlpha, + "upper-alpha" | "upper-latin" => crate::GeneratedCounterStyle::UpperAlpha, + "lower-roman" => crate::GeneratedCounterStyle::LowerRoman, + "upper-roman" => crate::GeneratedCounterStyle::UpperRoman, + _ => return None, + }) +} + +pub(crate) fn format_counter_value(value: i32, style: crate::GeneratedCounterStyle) -> String { + match style { + crate::GeneratedCounterStyle::Decimal => value.to_string(), + crate::GeneratedCounterStyle::DecimalLeadingZero if (-9..=9).contains(&value) => { + if value < 0 { + format!("-{:02}", value.unsigned_abs()) + } else { + format!("{value:02}") + } + } + crate::GeneratedCounterStyle::DecimalLeadingZero => value.to_string(), + crate::GeneratedCounterStyle::LowerAlpha => alpha_counter(value, false), + crate::GeneratedCounterStyle::UpperAlpha => alpha_counter(value, true), + crate::GeneratedCounterStyle::LowerRoman => roman_counter(value, false), + crate::GeneratedCounterStyle::UpperRoman => roman_counter(value, true), + } +} + +fn alpha_counter(value: i32, uppercase: bool) -> String { + if value <= 0 { + return value.to_string(); + } + let mut value = value as u32; + let mut result = Vec::new(); + while value > 0 { + value -= 1; + result.push((b'a' + (value % 26) as u8) as char); + value /= 26; + } + result.reverse(); + let result: String = result.into_iter().collect(); + if uppercase { + result.to_ascii_uppercase() + } else { + result + } +} + +fn roman_counter(value: i32, uppercase: bool) -> String { + if !(1..=3999).contains(&value) { + return value.to_string(); + } + let mut value = value; + let mut result = String::new(); + for &(amount, numeral) in &[ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ] { + while value >= amount { + value -= amount; + result.push_str(numeral); + } + } + if uppercase { + result + } else { + result.to_ascii_lowercase() + } +} + /// Decode CSS string escapes: `\` followed by 1-6 hex digits is a Unicode /// code point (`\200B` -> U+200B ZERO WIDTH SPACE, ubiquitous in generated /// `content` for accessible section-edit-link brackets and similar), with a @@ -2892,7 +3208,9 @@ mod tests { let cta = tree.query_selector("#cta").unwrap().unwrap(); assert_eq!( extract_content("content:attr(data-label)", &tree, cta), - Some(Some("Get Started".to_string())) + Some(Some(vec![crate::GeneratedContentItem::Text( + "Get Started".to_string() + )])) ); assert_eq!( extract_content(r#"content:"fallback";content:none"#, &tree, cta), @@ -2900,6 +3218,44 @@ mod tests { ); } + #[test] + fn generated_content_parses_counter_items_and_styles() { + let tree = obscura_dom::parse_html(r#""#); + let line = tree.query_selector("#line").unwrap().unwrap(); + assert_eq!( + extract_content( + r#"content:"[" counters(section, ".", upper-roman) "] " counter(line)"#, + &tree, + line, + ), + Some(Some(vec![ + crate::GeneratedContentItem::Text("[".to_string()), + crate::GeneratedContentItem::Counters { + name: "section".to_string(), + separator: ".".to_string(), + style: crate::GeneratedCounterStyle::UpperRoman, + }, + crate::GeneratedContentItem::Text("] ".to_string()), + crate::GeneratedContentItem::Counter { + name: "line".to_string(), + style: crate::GeneratedCounterStyle::Decimal, + }, + ])) + ); + assert_eq!( + format_counter_value(27, crate::GeneratedCounterStyle::LowerAlpha), + "aa" + ); + assert_eq!( + format_counter_value(14, crate::GeneratedCounterStyle::UpperRoman), + "XIV" + ); + assert_eq!( + format_counter_value(-4, crate::GeneratedCounterStyle::DecimalLeadingZero), + "-04" + ); + } + #[test] fn keyframes_retain_every_animation_offset() { let css = r#" @@ -3231,17 +3587,13 @@ mod tests { let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }"; let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0)); let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0)); - assert!( - narrow + assert!(narrow .iter() .any(|(selector, declarations)| selector == ".card" - && declarations.contains("width:100%")) - ); - assert!( - !wide + && declarations.contains("width:100%"))); + assert!(!wide .iter() - .any(|(_, declarations)| declarations.contains("width:100%")) - ); + .any(|(_, declarations)| declarations.contains("width:100%"))); } #[test] @@ -3487,6 +3839,63 @@ mod tests { ); } + #[test] + fn var_substitution_preserves_neighboring_token_boundaries() { + let props = HashMap::from([ + ("--stroke".to_string(), "2px".to_string()), + ("--amount".to_string(), "10".to_string()), + ]); + assert_eq!( + substitute_var_value("var(--stroke)solid", &props, 0).as_deref(), + Some("2px solid") + ); + assert_eq!( + substitute_var_value("calc(var(--stroke)*3)", &props, 0).as_deref(), + Some("calc(2px*3)"), + "punctuation adjacency must not gain calc-significant whitespace" + ); + assert_eq!( + substitute_var_value("calc(var(--amount)- 2px)", &props, 0).as_deref(), + Some("calc(10- 2px)"), + "substitution must not make an invalid unspaced calc minus valid" + ); + assert_eq!( + substitute_var_value("+var(--amount)", &props, 0).as_deref(), + Some("+ 10"), + "a delim plus followed by a number must not become a signed number token" + ); + assert_eq!( + substitute_var_value("var(--amount)%", &props, 0).as_deref(), + Some("10 %"), + "a number token followed by a percent delimiter is not a percentage token" + ); + + let tree = obscura_dom::parse_html(r#"
"#); + let css = r#" + #target { + --stroke:2px; + --ink:#123456; + border:var(--stroke)solid var(--ink); + height:calc(var(--stroke)*3); + } + "#; + let sheet = Stylesheet::parse(&tree, &[css.to_string()]); + let target = tree.get_element_by_id("target").unwrap(); + let (style, _) = + apply_registered_property_test_style(&sheet, &tree, target, &HashMap::new()); + assert_eq!( + style.border, + crate::Edges { + top: 2.0, + right: 2.0, + bottom: 2.0, + left: 2.0, + } + ); + assert_eq!(style.border_color, Some([0x12, 0x34, 0x56, 0xff])); + assert_eq!(style.height, crate::Dimension::Px(6.0)); + } + #[test] fn wildcard_duplicate_and_invalid_property_registrations_are_bounded() { let tree = obscura_dom::parse_html( diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 4c3b65b..38e9890 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -47,16 +47,190 @@ fn text_width( + chars * letter_spacing } +#[derive(Default)] +struct NativeButtonIntrinsicContent { + text: String, + atomic_width: f32, +} + +/// Collect the normal-flow content that contributes to an auto-sized +/// ` + + + "#, + ); + let laid = layout_dom(&tree, (800.0, 600.0)); + let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()]; + + assert!( + (rect("plain").width - rect("labels").width).abs() < 0.1, + "hidden/out-of-flow labels enlarged the button: plain={:?}, labels={:?}", + rect("plain"), + rect("labels") + ); + assert!( + (rect("icon").width - rect("icon-label").width).abs() < 0.1, + "display:none icon label enlarged the button: icon={:?}, labelled={:?}", + rect("icon"), + rect("icon-label") + ); + assert!( + (rect("icon").width - 32.0).abs() < 0.1, + "the in-flow 16px SVG and horizontal padding must contribute: {:?}", + rect("icon") + ); + } + #[test] fn text_alignment_does_not_shrink_wrap_block_grid_and_flex_children() { let tree = parse_html( @@ -8722,6 +9539,44 @@ mod tests { } } + #[test] + fn individual_translate_percentage_uses_own_border_box() { + let tree = parse_html( + r#" +
"#, + ); + let laid = layout_dom(&tree, (800.0, 600.0)); + let card = tree.get_element_by_id("card").unwrap(); + let rect = laid.rects[&card]; + let translated = laid.translates[&card]; + + assert!( + (rect.y - 206.0).abs() < 0.01, + "absolute top:50% must use the containing block: {rect:?}" + ); + assert!( + translated.0.abs() < 0.01 && (translated.1 + 140.0).abs() < 0.01, + "translate percentage must use the card's own 280px border box: {translated:?}" + ); + assert!( + laid.styles[&card].establishes_positioning_containing_block(), + "an individual translate creates a containing block like transform" + ); + } + #[test] fn sticky_normal_flow_retains_transformed_ancestor_coordinates() { let tree = parse_html( @@ -9057,8 +9912,7 @@ mod tests { .descendants(tree.document()) .into_iter() .find(|id| { - tree.get_node(*id) - .is_some_and(|node| { + tree.get_node(*id).is_some_and(|node| { node.as_element() .is_some_and(|element| element.local.as_ref() == "body") }) @@ -9081,8 +9935,7 @@ mod tests { .descendants(tree.document()) .into_iter() .find(|id| { - tree.get_node(*id) - .is_some_and(|node| { + tree.get_node(*id).is_some_and(|node| { node.as_element() .is_some_and(|element| element.local.as_ref() == "html") }) @@ -9100,7 +9953,8 @@ mod tests { fn viewport_overflow_clip_does_not_truncate_root_scrolling_overflow() { let tree = parse_html( r#"
"#, @@ -9112,6 +9966,45 @@ mod tests { assert_eq!(content, (900.0, 5048.0)); } + #[test] + fn propagated_body_overflow_is_visible_to_layout_and_does_not_create_a_bfc() { + let tree = parse_html( + r#" +
+ "#, + ); + let laid = layout_dom(&tree, (100.0, 60.0)); + let body = tree + .descendants(tree.document()) + .into_iter() + .find(|id| { + tree.get_node(*id).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "body") + }) + }) + .unwrap(); + let style = &laid.styles[&body]; + assert!(style.overflow_propagated_to_viewport); + assert!(!establishes_block_formatting_context(style)); + let taffy = crate::to_taffy_style(style); + assert_eq!(taffy.overflow.x, taffy::style::Overflow::Visible); + assert_eq!(taffy.overflow.y, taffy::style::Overflow::Visible); + } + + #[test] + fn html_owned_overflow_keeps_body_clip_in_root_scrolling_overflow() { + let tree = parse_html( + r#" + +
+ + "#, + ); + let laid = layout_dom(&tree, (80.0, 40.0)); + assert_eq!(laid.scrolling_content_size(&tree, (80.0, 40.0)), (100.0, 50.0)); + } + #[test] fn descendant_overflow_clip_still_bounds_root_scrolling_overflow() { let tree = parse_html( diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 12958b1..f1c2c7f 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -27,11 +27,10 @@ use obscura_dom::tree::{DomTree, NodeId}; use crate::{Dimension, Display, LayoutStyle, Rect, TextTransform}; // Bundled faces. Chrome on this class of host renders `sans-serif` and the -// ubiquitous Arial/Helvetica/system-ui stacks as Liberation Sans, `serif` as -// Liberation Serif, and `monospace` as Liberation Mono. Matching those keeps -// text metrics (advance widths, wrapping, line positions) aligned with Chromium -// instead of drifting ~12% wider on DejaVu's wider glyphs. DejaVu Sans is kept -// only as a last-resort fallback for glyphs the Liberation faces lack. +// ubiquitous Arial/Helvetica stacks as Liberation Sans, `system-ui` as DejaVu +// Sans, `serif` as Liberation Serif, and `monospace` as Liberation Mono. +// Matching those keeps text metrics (advance widths, wrapping, line positions) +// aligned with Chromium instead of drifting between unrelated host faces. static SANS_R: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); static SANS_B: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf"); static SANS_O: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); @@ -44,46 +43,80 @@ static MONO_R: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); static MONO_B: &[u8] = include_bytes!("../assets/liberation-mono-bold.ttf"); static MONO_O: &[u8] = include_bytes!("../assets/liberation-mono-oblique.ttf"); static MONO_BO: &[u8] = include_bytes!("../assets/liberation-mono-boldoblique.ttf"); -static FALLBACK: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); +static SYSTEM_R: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); +static SYSTEM_B: &[u8] = include_bytes!("../assets/dejavu-sans-bold.ttf"); +#[cfg(test)] +static FALLBACK: &[u8] = SYSTEM_R; const FAMILY: &str = "Liberation Sans"; const SERIF_FAMILY: &str = "Liberation Serif"; const MONO_FAMILY: &str = "Liberation Mono"; +const SYSTEM_FAMILY: &str = "DejaVu Sans"; /// Map a CSS `font-family` list to a bundled face the way Chromium resolves the -/// generic families on this host: a monospace/code stack -> Liberation Mono, a -/// serif stack -> Liberation Serif, everything else (sans-serif, Arial, -/// Helvetica, system-ui, named sans webfonts, ...) -> Liberation Sans. The first -/// family whose category is recognizable wins, matching CSS fallback order. +/// generic families on this host. Chromium's Linux `system-ui` resolves to +/// DejaVu Sans, while `sans-serif`/Arial/Helvetica resolve to Liberation Sans. +/// The first recognizable family wins, matching CSS fallback order. fn resolve_font_family(fam: Option<&str>) -> &'static str { let Some(f) = fam else { return FAMILY }; for tok in f.split(',') { - let t = tok.trim().trim_matches(|c| c == '"' || c == '\'').trim(); - if t.is_empty() { - continue; - } - if t == "monospace" || t.contains("mono") || t.contains("courier") - || t.contains("consol") || t == "menlo" || t == "monaco" || t == "code" - { - return MONO_FAMILY; - } - if t == "serif" || t == "georgia" || t.contains("times") || t == "cambria" - || t.contains("garamond") || t.contains("liberation serif") || t == "roman" - { - return SERIF_FAMILY; - } - if t == "sans-serif" || t.contains("sans") || t == "arial" || t == "helvetica" - || t == "helvetica neue" || t == "system-ui" || t == "-apple-system" - || t == "roboto" || t == "segoe ui" || t == "inter" || t == "verdana" - || t == "tahoma" || t == "ui-sans-serif" - { - return FAMILY; + if let Some(family) = bundled_family_for_css_token(tok) { + return family; } // Unrecognized named webfont: keep scanning for a generic fallback. } FAMILY } +fn bundled_family_for_css_token(token: &str) -> Option<&'static str> { + let token = token + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .trim() + .to_ascii_lowercase(); + if token.is_empty() { + return None; + } + if token == "system-ui" || token == "ui-sans-serif" { + return Some(SYSTEM_FAMILY); + } + if token == "monospace" + || token.contains("mono") + || token.contains("courier") + || token.contains("consol") + || token == "menlo" + || token == "monaco" + || token == "code" + { + return Some(MONO_FAMILY); + } + if token == "serif" + || token == "georgia" + || token.contains("times") + || token == "cambria" + || token.contains("garamond") + || token.contains("liberation serif") + || token == "roman" + { + return Some(SERIF_FAMILY); + } + if token == "sans-serif" + || token.contains("sans") + || token == "arial" + || token == "helvetica" + || token == "helvetica neue" + || token == "-apple-system" + || token == "roboto" + || token == "segoe ui" + || token == "inter" + || token == "verdana" + || token == "tahoma" + { + return Some(FAMILY); + } + None +} + #[derive(Clone)] struct LoadedFamily { faces: Vec, @@ -112,6 +145,7 @@ struct ResolvedFont { family: Arc, font_id: Option, metrics: FaceMetrics, + synthetic_italic: bool, } #[derive(Clone)] @@ -130,11 +164,32 @@ fn resolve_loaded_font( ) -> ResolvedFont { if let Some(stack) = fam { for token in stack.split(',') { - let name = token - .trim() - .trim_matches(|c| c == '"' || c == '\'') - .trim(); - if let Some(family) = loaded.get(&name.to_ascii_lowercase()) { + let name = token.trim().trim_matches(|c| c == '"' || c == '\'').trim(); + let family = loaded.get(&name.to_ascii_lowercase()).or_else(|| { + bundled_family_for_css_token(name) + .and_then(|family| loaded.get(&family.to_ascii_lowercase())) + }); + if let Some(resolved) = family + .and_then(|family| select_loaded_face(family, requested_weight, requested_italic)) + { + return resolved; + } + } + } + let fallback = resolve_font_family(fam); + ResolvedFont { + family: Arc::from(fallback), + font_id: None, + metrics: bundled_face_metrics(fallback), + synthetic_italic: false, + } +} + +fn select_loaded_face( + family: &LoadedFamily, + requested_weight: u16, + requested_italic: bool, +) -> Option { let exact_style: Vec<_> = family .faces .iter() @@ -150,39 +205,28 @@ fn resolve_loaded_font( .copied() .find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight)) { - // The named-family matcher uses fontdb's default weight for - // this resource, while a variable face commonly advertises - // `100 900` in CSS. Preserve the descriptor-selected file - // and its database weight; the authored coordinate enters - // the canonical axis tuple below. - return ResolvedFont { + // The named-family matcher uses fontdb's default weight for this + // resource, while a variable face commonly advertises `100 900` in + // CSS. Preserve the descriptor-selected file and its database weight; + // the authored coordinate enters the canonical axis tuple below. + return Some(ResolvedFont { family: Arc::clone(&face.name), font_id: face.font_id, metrics: face.metrics, - }; + synthetic_italic: requested_italic && !face.italic, + }); } - let available: Vec<_> = - candidates.iter().map(|face| face.min_weight).collect(); + let available: Vec<_> = candidates.iter().map(|face| face.min_weight).collect(); let matched = match_font_weight(requested_weight, &available); - if let Some(face) = candidates + candidates .into_iter() .find(|face| face.min_weight == matched) - { - return ResolvedFont { + .map(|face| ResolvedFont { family: Arc::clone(&face.name), font_id: face.font_id, metrics: face.metrics, - }; - } - } - } - } - let fallback = resolve_font_family(fam); - ResolvedFont { - family: Arc::from(fallback), - font_id: None, - metrics: bundled_face_metrics(fallback), - } + synthetic_italic: requested_italic && !face.italic, + }) } /// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects @@ -238,6 +282,7 @@ fn bundled_face_metrics(family: &str) -> FaceMetrics { let (ascent, descent, line_gap) = match family { SERIF_FAMILY => (1825.0, 443.0, 87.0), MONO_FAMILY => (1705.0, 615.0, 0.0), + SYSTEM_FAMILY => (1901.0, 483.0, 0.0), _ => (1854.0, 434.0, 67.0), }; FaceMetrics { @@ -580,11 +625,8 @@ impl ReplacedItem { .as_deref() .map_or(false, |expression| expression.contains('%')) }; - let intrinsic_ratio = if width.is_finite() - && height.is_finite() - && width > 0.0 - && height > 0.0 - { + let intrinsic_ratio = + if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { width / height } else { 1.0 @@ -740,10 +782,8 @@ impl TextEngine { let mut db = cosmic_text::fontdb::Database::new(); let mut declarations = Vec::new(); for bytes in [ - SANS_R, SANS_B, SANS_O, SANS_BO, - SERIF_R, SERIF_B, SERIF_O, SERIF_BO, - MONO_R, MONO_B, MONO_O, MONO_BO, - FALLBACK, + SANS_R, SANS_B, SANS_O, SANS_BO, SERIF_R, SERIF_B, SERIF_O, SERIF_BO, MONO_R, MONO_B, + MONO_O, MONO_BO, SYSTEM_R, SYSTEM_B, ] { for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))) { declarations.push((id, None, None, None)); @@ -936,6 +976,7 @@ impl TextEngine { font_id: context.font_id, variations: context.variations.clone(), italic: context.italic, + synthetic_italic: context.synthetic_italic, underline: context.underline, color: context.color, family: context.family, @@ -977,9 +1018,7 @@ impl TextEngine { } } } - if spans - .iter() - .all(|(text, _)| { + if spans.iter().all(|(text, _)| { text.is_empty() || (matches!( white_space, @@ -988,8 +1027,7 @@ impl TextEngine { | crate::WhiteSpace::PreLine ) && text.trim().is_empty() && !text.contains('\n')) - }) - { + }) { return None; } @@ -1208,6 +1246,7 @@ struct SpanAttrs { font_id: Option, variations: Option>, italic: bool, + synthetic_italic: bool, underline: bool, color: [u8; 4], family: Arc, @@ -1234,7 +1273,14 @@ impl SpanAttrs { if let Some(font_id) = self.font_id { a = a.font_id(font_id); } - a = a.style(if self.italic { Style::Italic } else { Style::Normal }); + a = a.style(if self.italic { + Style::Italic + } else { + Style::Normal + }); + if self.synthetic_italic { + a = a.cache_key_flags(CacheKeyFlags::FAKE_ITALIC); + } if self.letter_spacing.is_finite() && self.letter_spacing != 0.0 { a = a.letter_spacing(self.letter_spacing / self.font_size.max(1.0)); } @@ -1280,6 +1326,7 @@ struct SpanCtx { font_metrics: FaceMetrics, variations: Option>, italic: bool, + synthetic_italic: bool, underline: bool, transform: TextTransform, white_space: crate::WhiteSpace, @@ -1345,6 +1392,7 @@ fn base_span_ctx( font_metrics: font.metrics, variations, italic: base.font_style_italic.unwrap_or(false), + synthetic_italic: font.synthetic_italic, underline: base.underline.unwrap_or(false), transform: base.text_transform.unwrap_or(TextTransform::None), white_space: base.white_space.unwrap_or_default(), @@ -1401,6 +1449,7 @@ fn collect_node_spans( font_id: ctx.font_id, variations: ctx.variations.clone(), italic: ctx.italic, + synthetic_italic: ctx.synthetic_italic, underline: ctx.underline, color: ctx.color, family: Arc::clone(&ctx.family), @@ -1422,7 +1471,9 @@ fn collect_node_spans( return; } if elem.local.as_ref() == "br" { - out.push(("\n".to_string(), SpanAttrs { + out.push(( + "\n".to_string(), + SpanAttrs { font_size: ctx.font_size, line_height: ctx.line_height, letter_spacing: ctx.letter_spacing, @@ -1432,11 +1483,13 @@ fn collect_node_spans( font_id: ctx.font_id, variations: ctx.variations.clone(), italic: ctx.italic, + synthetic_italic: ctx.synthetic_italic, underline: ctx.underline, color: ctx.color, family: Arc::clone(&ctx.family), clip_fill: ctx.clip_fill, - })); + }, + )); c.last_was_space = true; return; } @@ -1471,6 +1524,7 @@ fn collect_node_spans( family: Arc::clone(&ctx.family), font_id: ctx.font_id, metrics: ctx.font_metrics, + synthetic_italic: ctx.synthetic_italic, }); let variations = style .map(|style| resolved_font_variations(style, &font)) @@ -1497,6 +1551,7 @@ fn collect_node_spans( font_metrics: font.metrics, variations, italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false), + synthetic_italic: font.synthetic_italic, // Underline propagates in: an ancestor's underline covers // descendant text; an element only sets its own via CSS. underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false), @@ -2066,13 +2121,20 @@ impl TextEngine { let out_r = sr + (dst.red() as u32 * inv / 255); let out_g = sg + (dst.green() as u32 * inv / 255); let out_b = sb + (dst.blue() as u32 * inv / 255); - pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8) + pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba( + out_r as u8, + out_g as u8, + out_b as u8, + out_a as u8, + ) .unwrap_or(dst); }; let explicit_variations = metadata_variation(glyph.metadata) .and_then(|index| item.variation_sets.get(index)) .map(Arc::clone); - let effective_variations = glyph.font_is_variable.then(|| { + let effective_variations = glyph + .font_is_variable + .then(|| { variable_swash.effective_variations( font_system, physical.cache_key.font_id, @@ -2081,7 +2143,8 @@ impl TextEngine { glyph.font_italic_axis, explicit_variations, ) - }).flatten(); + }) + .flatten(); if let Some(variations) = effective_variations { variable_swash.with_pixels( font_system, @@ -2159,6 +2222,92 @@ mod tests { const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; + #[test] + fn system_ui_resolves_in_stack_order_to_chromium_linux_face() { + let stack = "system-ui, -apple-system, \"Segoe UI\", Roboto, \ + \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", \ + Arial, sans-serif"; + assert_eq!(resolve_font_family(Some(stack)), SYSTEM_FAMILY); + + let mut engine = TextEngine::new(); + let system = resolve_loaded_font(Some(stack), 600, false, &engine.loaded_families); + assert_eq!(system.family.as_ref(), SYSTEM_FAMILY); + let face = engine + .font_system + .db() + .face(system.font_id.expect("bundled system-ui face")) + .expect("system-ui database face"); + assert_eq!( + face.weight.0, 700, + "CSS weight 600 selects DejaVu Sans Bold just as Chromium does" + ); + + let italic = resolve_loaded_font(Some("system-ui"), 400, true, &engine.loaded_families); + let italic_face = engine + .font_system + .db() + .face(italic.font_id.expect("bundled system-ui italic fallback")) + .expect("system-ui italic fallback database face"); + assert_eq!(italic_face.weight.0, 400); + assert_eq!( + italic_face.style, + cosmic_text::fontdb::Style::Normal, + "Chromium synthesizes system-ui italic from DejaVu Sans regular on this host" + ); + + let bold_italic = + resolve_loaded_font(Some("system-ui"), 700, true, &engine.loaded_families); + let bold_italic_face = engine + .font_system + .db() + .face( + bold_italic + .font_id + .expect("bundled system-ui bold italic fallback"), + ) + .expect("system-ui bold italic fallback database face"); + assert_eq!(bold_italic_face.weight.0, 700); + assert_eq!( + bold_italic_face.style, + cosmic_text::fontdb::Style::Normal, + "Chromium synthesizes system-ui bold italic from DejaVu Sans Bold on this host" + ); + + let tree = obscura_dom::parse_html("

Italic system UI

"); + let copy = tree.get_element_by_id("copy").unwrap(); + let mut style = LayoutStyle::default(); + style.display = Display::Block; + style.font_family = Some("system-ui".to_string()); + style.font_style_italic = Some(true); + style.font_size = Some(32.0); + let item = engine + .try_build(&tree, copy, &HashMap::from([(copy, style)])) + .expect("system-ui italic shapes"); + engine.measure(item, Some(400.0)); + let glyph = engine.items[item] + .buffer + .layout_runs() + .next() + .and_then(|run| run.glyphs.first()) + .expect("system-ui italic glyph"); + assert!( + glyph + .physical((0.0, 0.0), 1.0) + .cache_key + .flags + .contains(CacheKeyFlags::FAKE_ITALIC), + "the normal DejaVu resource must retain the requested synthetic slant" + ); + + let arial = resolve_loaded_font( + Some("Arial, sans-serif"), + 600, + false, + &engine.loaded_families, + ); + assert_eq!(arial.family.as_ref(), FAMILY); + } + #[test] fn normal_line_height_grid_fits_each_face_metric() { // Values measured from Chromium 145 using the same bundled Linux @@ -2540,6 +2689,7 @@ mod tests { font_id: None, variations: Some(Arc::new(variations)), italic: false, + synthetic_italic: false, underline: true, color: [1, 2, 3, 255], family: Arc::from(FAMILY), @@ -2569,6 +2719,7 @@ mod tests { family: Arc::from(FAMILY), font_id: None, metrics: bundled_face_metrics(FAMILY), + synthetic_italic: false, }; assert!(resolved_font_variations(&style, &static_font).is_none()); let styles = HashMap::from([(copy, style)]); @@ -2598,6 +2749,7 @@ mod tests { family: Arc::from(FAMILY), font_id: None, metrics: bundled_face_metrics(FAMILY), + synthetic_italic: false, }; assert!(resolved_font_variations(&style, &font).is_none()); } @@ -2614,6 +2766,7 @@ mod tests { font_id: None, variations: None, italic: false, + synthetic_italic: false, underline: false, color: [0, 0, 0, 255], family: Arc::from(FAMILY), diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 37a7a78..e441a74 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -235,6 +235,65 @@ impl BorderRadius { } } +/// One axis of a CSS `background-position`. +/// +/// CSS positions combine an absolute offset with a percentage of the space +/// left after sizing the background image. Keeping both terms is necessary +/// for sprite sheets: `-24px` is an offset from the start edge, while +/// `right 10px` is equivalent to `100% - 10px`. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct BackgroundPositionAxis { + length: f32, + percentage: f32, +} + +impl BackgroundPositionAxis { + pub const fn pixels(length: f32) -> Self { + Self { + length, + percentage: 0.0, + } + } + + pub const fn percentage(percentage: f32) -> Self { + Self { + length: 0.0, + percentage, + } + } + + pub const fn length_percentage(length: f32, percentage: f32) -> Self { + Self { length, percentage } + } + + pub const fn from_end_offset(offset: Self) -> Self { + Self { + length: -offset.length, + percentage: 1.0 - offset.percentage, + } + } + + pub fn resolve(self, leftover_space: f32) -> f32 { + self.length + self.percentage * leftover_space + } +} + +/// The two axes of CSS `background-position`. +/// +/// The derived default is the CSS initial value `0% 0%`. A one-value +/// longhand such as `background-position: 0` is parsed as `0 center`. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct BackgroundPosition { + pub x: BackgroundPositionAxis, + pub y: BackgroundPositionAxis, +} + +impl BackgroundPosition { + pub const fn new(x: BackgroundPositionAxis, y: BackgroundPositionAxis) -> Self { + Self { x, y } + } +} + /// Fill rule for a CSS `clip-path: polygon(...)`. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ClipPathFillRule { @@ -255,6 +314,69 @@ pub struct ClipPathPolygon { pub points: Vec<(Dimension, Dimension)>, } +/// One CSS gradient from the ordered `background-image` layer list. +/// +/// CSS paints the first authored layer closest to the user, so paint walks +/// this vector in reverse over the background color. Keeping the authored +/// order is essential when a hero combines a linear fade with several +/// partially transparent radial highlights. +#[derive(Debug, Clone, PartialEq)] +pub enum BackgroundGradientLayer { + Linear { + angle: f32, + stops: Vec<([u8; 4], Option)>, + /// Authored stop positions, retained until paint so absolute lengths + /// can resolve against the final gradient-line length. + stop_positions: Vec>, + /// `repeating-linear-gradient()` repeats the interval from its first + /// resolved stop through its last, independently of background tiling. + repeating: bool, + }, + Radial { + center: (f32, f32), + stops: Vec<([u8; 4], Option)>, + }, + Conic { + angle: f32, + center: (f32, f32), + stops: Vec<([u8; 4], Option)>, + }, +} + +/// One computed CSS counter operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CounterDirective { + pub name: String, + pub value: i32, +} + +/// Counter styles supported in generated text. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GeneratedCounterStyle { + #[default] + Decimal, + DecimalLeadingZero, + LowerAlpha, + UpperAlpha, + LowerRoman, + UpperRoman, +} + +/// One item in a computed `content` value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GeneratedContentItem { + Text(String), + Counter { + name: String, + style: GeneratedCounterStyle, + }, + Counters { + name: String, + separator: String, + style: GeneratedCounterStyle, + }, +} + /// The subset of CSS that influences box layout. Expanded in later phases. #[derive(Debug, Clone, Default)] pub struct LayoutStyle { @@ -285,6 +407,12 @@ pub struct LayoutStyle { /// descendants while leaving auto-width blocks fill-available. pub legacy_center: bool, pub width: Dimension, + /// The preferred inline size is the intrinsic `fit-content` keyword. + /// + /// Taffy's box-size dimension cannot represent intrinsic sizing keywords, + /// so `width` remains `Auto` while the DOM layout convergence pass applies + /// the CSS shrink-to-fit formula from min/max-content measurements. + pub width_fit_content: bool, pub height: Dimension, /// Which box edge `width`/`height` and min/max sizes describe. CSS starts /// at `content-box`; many modern reset sheets opt into `border-box`. @@ -373,8 +501,10 @@ pub struct LayoutStyle { /// the center is a fraction of the border box, and stops are normalized /// during paint. Conic gradients commonly provide the color source for a /// repeated SVG mask in modern hero artwork. - pub background_conic_gradient: - Option<(f32, (f32, f32), Vec<([u8; 4], Option)>)>, + pub background_conic_gradient: Option<(f32, (f32, f32), Vec<([u8; 4], Option)>)>, + /// Every parsed gradient in authored background-layer order. The legacy + /// single-kind fields above remain populated for mask/text fast paths. + pub background_gradient_layers: Vec, /// The first `url(...)` reference from `background`/`background-image` /// (gradients and repeat keywords in the same shorthand are ignored: we /// paint the referenced image, not the gradient layer). @@ -389,10 +519,13 @@ pub struct LayoutStyle { /// Keyword `background-size` behavior. `None` is CSS `auto`, which uses /// the image's intrinsic dimensions rather than stretching it to the box. pub background_size_fit: Option, - /// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default - /// top-left; 1,0.5 = "right center"). The fraction applies to the leftover - /// space after resolving explicit, intrinsic, cover, or contain size. - pub background_position: (f32, f32), + /// `background-position`, retained as a length-plus-percentage per axis. + /// Percentages apply to the leftover space after resolving explicit, + /// intrinsic, cover, or contain size. + pub background_position: BackgroundPosition, + /// Explicit `(repeat-x, repeat-y)` choice. `None` is the CSS initial + /// `repeat` in both axes. + pub background_repeat: Option<(bool, bool)>, /// `background-clip: text` / `-webkit-background-clip: text`: the background /// paints only through the element's glyphs, not as a filled box. Combined /// with a transparent text color this is the common gradient-text technique @@ -571,6 +704,22 @@ pub struct LayoutStyle { /// clipped box used for skip-links and screen-reader-only labels) actually /// invisible instead of painting its text wherever it lands. pub overflow_hidden: bool, + /// Independent computed overflow clips. `overflow_hidden` remains the + /// aggregate compatibility/BFC flag; paint and automatic minimum sizing + /// must consult the relevant axis. + pub overflow_clip_x: bool, + pub overflow_clip_y: bool, + pub(crate) overflow_axes_set: bool, + pub(crate) overflow_specified_x: u8, + pub(crate) overflow_specified_y: u8, + pub(crate) overflow_inherit_x: bool, + pub(crate) overflow_inherit_y: bool, + pub(crate) overflow_scroll_x: bool, + pub(crate) overflow_scroll_y: bool, + /// This element's authored overflow is propagated to the viewport. Its + /// own box therefore behaves as `overflow: visible` for layout/BFC + /// purposes while the capture viewport supplies the paint clip. + pub(crate) overflow_propagated_to_viewport: bool, /// Whether computed overflow establishes a scroll container. `clip` /// clips paint but deliberately does not establish one, which matters for /// selecting the scrollport that controls a sticky descendant. @@ -611,15 +760,16 @@ pub struct LayoutStyle { /// `clear`, when set: this element moves below preceding floats on the /// given side(s), ending their float zone. pub clear: Option, + /// Non-inherited CSS counter operations in computed declaration order. + /// Reset operations run before increments on the same element. + pub counter_reset: Vec, + pub counter_increment: Vec, + pub counter_set: Vec, /// Resolved during the inheritance pass: true when this element should /// not be painted at all, either from its own or an inherited /// `visibility: hidden`, or because the product of its own and every - /// ancestor's `opacity` has collapsed to (near) zero. Real `opacity` - /// composites as translucent groups, which our paint step does not do; - /// collapsing it to a binary paint/don't-paint decision is enough to - /// make the extremely common "opacity:0 + visibility:hidden" collapsed - /// dropdown/panel pattern actually invisible, without needing full alpha - /// compositing. + /// ancestor's `opacity` is zero. Fractional values remain paintable and + /// are isolated into composited groups by the paint pass. pub effectively_invisible: bool, /// A CSS image supplied by `content: url(...)` on a replaced element. @@ -633,6 +783,9 @@ pub struct LayoutStyle { /// children, same as if it were real text content. pub before_content: Option, pub after_content: Option, + /// Typed computed `content` items retained until the document-order + /// counter pass can resolve `counter()` and `counters()`. + pub generated_content: Option>, /// Computed boxes generated by `::before`/`::after`. Text-only pseudos /// continue through `before_content`/`after_content`; these styles retain /// positioned decorative boxes for layout-independent painting. @@ -696,9 +849,10 @@ pub struct LayoutStyle { /// (this is what underlines links, which are underlined by UA default). pub underline: Option, - /// `font-style: italic|oblique`. Inherited. Selects the oblique face when - /// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None` - /// means inherit. + /// `font-style: italic|oblique`. Inherited. Selects an available oblique + /// face when shaping; the bundled Linux `system-ui` face synthesizes its + /// slant from DejaVu Sans regular/bold to match Chromium. `None` means + /// inherit. pub font_style_italic: Option, /// `object-fit` for a replaced element (``). Controls how the decoded @@ -715,6 +869,12 @@ pub struct LayoutStyle { /// off-screen skip-link out of view instead of painting it on-screen. Not /// inherited (transform is a non-inherited property). pub transform_translate: Option<(Dimension, Dimension)>, + /// Individual CSS `translate` property. This composes independently with + /// the legacy `transform` property, so `transform:none` must not clear it. + /// Functional values are retained separately until the final border box + /// is known because percentages resolve against that box's own axes. + pub individual_translate: Option<(Dimension, Dimension)>, + pub individual_translate_expressions: [Option; 2], /// Independent CSS-property triggers that establish containing blocks for /// absolute and fixed descendants. Kept as a bitset so `filter:none` /// cannot clear a transform/containment trigger from another property. @@ -766,11 +926,28 @@ pub(crate) const CB_TRIGGER_PERSPECTIVE: u16 = 1 << 3; pub(crate) const CB_TRIGGER_CONTAIN: u16 = 1 << 4; pub(crate) const CB_TRIGGER_WILL_CHANGE: u16 = 1 << 5; pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6; +pub(crate) const CB_TRIGGER_TRANSLATE: u16 = 1 << 7; impl LayoutStyle { pub(crate) fn establishes_positioning_containing_block(&self) -> bool { self.containing_block_triggers != 0 } + + pub(crate) fn clips_overflow_x(&self) -> bool { + if self.overflow_axes_set { + self.overflow_clip_x + } else { + self.overflow_hidden + } + } + + pub(crate) fn clips_overflow_y(&self) -> bool { + if self.overflow_axes_set { + self.overflow_clip_y + } else { + self.overflow_hidden + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1070,15 +1247,27 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { width: dimension(style.width), height: dimension(style.height), }; - // Tell taffy's own layout algorithm about `overflow: hidden`, not just our - // paint-time clip rects: per spec, a flex/grid item's automatic minimum - // size is content-based only when its overflow is `visible`, and `0` - // otherwise. Without this, an explicit `height: 0; overflow: hidden` - // element (the common collapsed-dropdown-panel pattern: hidden until a - // sibling checkbox is checked) still grows to fit its content, since - // taffy has no way to know it should not. - if style.overflow_hidden { - s.overflow = taffy::Point { x: taffy::style::Overflow::Hidden, y: taffy::style::Overflow::Hidden }; + // Tell taffy's layout algorithm about computed overflow, not just our + // paint-time clips: a flex/grid automatic minimum depends on the relevant + // axis. Overflow propagated from html/body belongs to the viewport and + // must leave the source element's own layout overflow visible. + if style.overflow_hidden && !style.overflow_propagated_to_viewport { + s.overflow = taffy::Point { + x: if style.overflow_scroll_x { + taffy::style::Overflow::Hidden + } else if style.clips_overflow_x() { + taffy::style::Overflow::Clip + } else { + taffy::style::Overflow::Visible + }, + y: if style.overflow_scroll_y { + taffy::style::Overflow::Hidden + } else if style.clips_overflow_y() { + taffy::style::Overflow::Clip + } else { + taffy::style::Overflow::Visible + }, + }; } s.min_size = taffy::Size { width: dimension(style.min_width), diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index b5ef095..1e54039 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -6,12 +6,16 @@ //! dependencies, so a screenshot is reproducible across hosts. use obscura_dom::tree::DomTree; -use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, RadialGradient, Rect, SpreadMode, Transform}; use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; +use tiny_skia::{ + Color, FillRule, FilterQuality, GradientStop, LinearGradient, Paint, PathBuilder, Pattern, + Pixmap, Point, RadialGradient, Rect, SpreadMode, Transform, +}; static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); +static SYSTEM_FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf"); static MONO_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); static FONT_BOLD_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf"); @@ -519,6 +523,8 @@ pub fn paint_prepared( &prepared.viewport_fixed, &prepared.sticky, &mut prepared.layout, + None, + None, ) } @@ -538,6 +544,8 @@ fn paint_laid_dom_scrolled( viewport_fixed: &std::collections::HashSet, sticky: &crate::StickyLayout, laid: &mut crate::DomLayout, + paint_root: Option, + suppress_opacity_for: Option, ) -> Option { let scroll_state = ScrollPaintState::new(viewport, scroll, content_size, viewport_fixed, sticky); @@ -557,6 +565,12 @@ fn paint_laid_dom_scrolled( // box/text loop below and the inline-formatting loop after it (an svg // `` element must not also paint its glyphs on top of the raster). let mut svg_subtree_skip: std::collections::HashSet = std::collections::HashSet::new(); + // Text is painted in a second pass, after the box-order loop. Remember + // every subtree already painted into an opacity layer so its shaped text + // is not drawn a second time at full opacity in the outer pass. + let mut opacity_subtree_skip: std::collections::HashSet< + obscura_dom::tree::NodeId, + > = std::collections::HashSet::new(); // External sprite symbols, keyed by "url#id", extracted from a fetched // sprite file so a `` resolves. One sprite backs many // icons (a whole logo/icon band), so cache the parsed symbol across every @@ -580,17 +594,38 @@ fn paint_laid_dom_scrolled( let mut pos_layers: Vec<(i32, Vec)> = Vec::new(); let mut normal: Vec = Vec::new(); let mut consumed: std::collections::HashSet = std::collections::HashSet::new(); - for nid in tree.descendants(tree.document()) { + let mut paint_nodes = paint_root.into_iter().collect::>(); + paint_nodes.extend(tree.descendants(paint_root.unwrap_or_else(|| tree.document()))); + for nid in paint_nodes.iter().copied() { if consumed.contains(&nid) { continue; } + let is_opacity_root = suppress_opacity_for != Some(nid) + && laid + .styles + .get(&nid) + .and_then(|style| style.opacity) + .is_some_and(|opacity| opacity.clamp(0.0, 1.0) < 1.0); let z = laid .styles .get(&nid) .filter(|s| s.position.is_some()) .and_then(|s| s.z_index) .filter(|&z| z != 0); - if let Some(z) = z { + if is_opacity_root { + let mut sub = vec![nid]; + sub.extend(tree.descendants(nid)); + for &member in &sub { + consumed.insert(member); + } + // An opacity effect is one atomic paint-order unit. Its internal + // z-order is resolved while painting its isolated surface. + match z { + Some(z) if z < 0 => neg_layers.push((z, vec![nid])), + Some(z) => pos_layers.push((z, vec![nid])), + None => normal.push(nid), + } + } else if let Some(z) = z { let mut sub = vec![nid]; sub.extend(tree.descendants(nid)); for &m in &sub { @@ -635,25 +670,29 @@ fn paint_laid_dom_scrolled( .collect(); let mut last_index: std::collections::HashMap = paint_indices.clone(); - let dom_preorder = tree.descendants(tree.document()); + let dom_preorder = paint_nodes.clone(); for nid in dom_preorder.into_iter().rev() { let Some(index) = last_index.get(&nid).copied() else { continue; }; - if let Some(parent) = tree.get_node(nid).and_then(|node| node.parent) { - last_index - .entry(parent) - .and_modify(|last| *last = (*last).max(index)) - .or_insert(index); + if paint_root != Some(nid) { + if let Some(parent) = tree.get_node(nid).and_then(|node| node.parent) { + last_index + .entry(parent) + .and_modify(|last| *last = (*last).max(index)) + .or_insert(index); + } } } for generated in laid.generated_boxes.iter().copied() { match generated.kind { crate::dom::GeneratedBoxKind::Before => { - generated_before - .entry(generated.host) - .or_default() - .push(generated); + if paint_indices.contains_key(&generated.host) { + generated_before + .entry(generated.host) + .or_default() + .push(generated); + } } crate::dom::GeneratedBoxKind::After => { if let Some(index) = last_index.get(&generated.host) { @@ -699,6 +738,53 @@ fn paint_laid_dom_scrolled( None => continue, }; + let own_opacity = laid + .styles + .get(&nid) + .and_then(|style| style.opacity) + .unwrap_or(1.0) + .clamp(0.0, 1.0); + if suppress_opacity_for != Some(nid) && own_opacity < 1.0 { + opacity_subtree_skip.insert(nid); + opacity_subtree_skip.extend(tree.descendants(nid)); + if own_opacity <= 0.0 { + continue; + } + // Opacity is applied to the finished stacking context, never to + // each primitive. Otherwise two opaque overlapping children at + // opacity:.5 incorrectly become .75 alpha in their overlap. + let layer = Pixmap::new(pixmap.width(), pixmap.height())?; + let layer = paint_laid_dom_scrolled( + tree, + viewport, + base_url, + scroll, + layer, + image_cache, + selected_images, + svg_fonts, + content_size, + viewport_fixed, + sticky, + laid, + Some(nid), + Some(nid), + )?; + let group_paint = tiny_skia::PixmapPaint { + opacity: own_opacity, + ..tiny_skia::PixmapPaint::default() + }; + pixmap.draw_pixmap( + 0, + 0, + layer.as_ref(), + &group_paint, + Transform::identity(), + None, + ); + continue; + } + let style = match laid.styles.get(&nid) { Some(s) => s, None => continue, @@ -760,7 +846,8 @@ fn paint_laid_dom_scrolled( // Box path (rounded if border-radius), reused for gradient/color fill. let radius = style.border_radius.resolve(rect.width, rect.height); let has_radius = radius.0 > 0.5 && radius.1 > 0.5; - let bg_path = || if has_radius { + let bg_path = || { + if has_radius { rounded_rect_path( visible_rect.x, visible_rect.y, @@ -773,6 +860,7 @@ fn paint_laid_dom_scrolled( let mut pb = PathBuilder::new(); pb.push_rect(box_rect); pb.finish() + } }; // A linear-gradient background (heavily used by modern hero sections); // without this it paints white. Takes precedence over a solid color. @@ -793,6 +881,20 @@ fn paint_laid_dom_scrolled( ); } } + if !style.background_gradient_layers.is_empty() { + if let Some(path) = bg_path() { + paint_background_gradient_layers( + &mut pixmap, + &path, + &visible_rect, + radius, + style, + root_font_size, + viewport, + clip_path_mask.as_ref(), + ); + } + } else { if let Some((center, stops)) = &style.background_radial_gradient { if let Some(path) = bg_path() { paint_radial_gradient( @@ -829,6 +931,7 @@ fn paint_laid_dom_scrolled( } } } + } if let Some(mask_url) = &style.mask_image { let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]); @@ -885,7 +988,45 @@ fn paint_laid_dom_scrolled( } } } - for pseudo in [style.before_pseudo.as_deref(), style.after_pseudo.as_deref()] + let positioned_pseudo_containing_block = { + let mut ancestor = Some(nid); + let mut found = None; + while let Some(candidate) = ancestor { + let candidate_style = laid.styles.get(&candidate); + let establishes = candidate_style.is_some_and(|candidate_style| { + candidate_style.position.is_some() + || candidate_style.establishes_positioning_containing_block() + }); + if establishes { + if let Some(candidate_rect) = laid.rects.get(&candidate).copied() { + let (candidate_x, candidate_y) = + scroll_state.translation_for(laid, candidate); + found = Some(crate::Rect { + x: candidate_rect.x + candidate_x, + y: candidate_rect.y + candidate_y, + width: candidate_rect.width, + height: candidate_rect.height, + }); + break; + } + // A positioned inline can be flattened out of the taffy + // tree when its block children form the real layout + // boxes. It is still the CSS containing block, but has no + // usable rectangle in this representation. Keep walking + // to the nearest positioned ancestor that does have one + // instead of falling back to the host line itself. The + // latter places `left:0` generated gutters directly over + // their margin-shifted text. + } + ancestor = tree.get_node(candidate).and_then(|node| node.parent); + } + found.unwrap_or(rect) + }; + let positioned_pseudo_clip = scroll_state.descendant_clip_for(laid, nid); + for pseudo in [ + style.before_pseudo.as_deref(), + style.after_pseudo.as_deref(), + ] .into_iter() .flatten() { @@ -893,10 +1034,11 @@ fn paint_laid_dom_scrolled( &mut laid.text_engine, &mut pixmap, pseudo, + &positioned_pseudo_containing_block, &rect, viewport, root_font_size, - clip, + positioned_pseudo_clip, base_url, image_cache, ); @@ -976,8 +1118,7 @@ fn paint_laid_dom_scrolled( // ancestor overflow clip: the raster must not paint past it (a // half-scrolled carousel slide's image otherwise bleeds over // the viewport edge). - let painted = - paint_image( + let painted = paint_image( &source.resolved_url, None, &rect, @@ -1034,7 +1175,12 @@ fn paint_laid_dom_scrolled( // is drawn at its full border-box size (undistorted) and clipped to the // overflow-visible region. if name.local.as_ref() == "svg" { - let mut markup = serialize_svg_styled(tree, nid, &laid.styles); + let mut markup = serialize_svg_styled( + tree, + nid, + &laid.styles, + (suppress_opacity_for == Some(nid)).then_some(nid), + ); // Resolve referenced symbols before carrying the host color into // the standalone document. A document-level/external symbol may // itself contain `currentColor`, and therefore has to be present @@ -1240,8 +1386,8 @@ fn paint_laid_dom_scrolled( // cells, labels) draw last, in tree order, so their glyphs sit above the // box backgrounds/borders painted in the loop above. Each item already // carries its final origin and clip from `TextEngine::finalize`. - for nid in tree.descendants(tree.document()) { - if svg_subtree_skip.contains(&nid) { + for nid in paint_nodes { + if svg_subtree_skip.contains(&nid) || opacity_subtree_skip.contains(&nid) { continue; } let whole = laid.ifc_items.get(&nid).copied(); @@ -1280,6 +1426,7 @@ fn paint_laid_dom_scrolled( /// frame. #[derive(Debug)] struct ScrollPaintState<'a> { + viewport: (f32, f32), scroll: (f32, f32), viewport_fixed: &'a std::collections::HashSet, sticky: std::collections::HashMap, @@ -1313,6 +1460,7 @@ impl<'a> ScrollPaintState<'a> { let active = scroll != (0.0, 0.0) || !sticky_layout.is_empty(); if !active { return Self { + viewport, scroll, viewport_fixed, sticky: std::collections::HashMap::new(), @@ -1324,6 +1472,7 @@ impl<'a> ScrollPaintState<'a> { let sticky = sticky_layout.translations(viewport, scroll); let sticky_clips = sticky_layout.clip_translations_from(&sticky); Self { + viewport, scroll, viewport_fixed, sticky, @@ -1353,6 +1502,15 @@ impl<'a> ScrollPaintState<'a> { laid: &crate::DomLayout, id: obscura_dom::tree::NodeId, ) -> Option { + self.overflow_clip_for(laid, id) + .map(|clip| clip.viewport_rect(self.viewport)) + } + + fn overflow_clip_for( + &self, + laid: &crate::DomLayout, + id: obscura_dom::tree::NodeId, + ) -> Option { let mut clip = laid.clip_rects.get(&id).copied().flatten()?; if !self.active || self.viewport_fixed.contains(&id) { return Some(clip); @@ -1362,11 +1520,35 @@ impl<'a> ScrollPaintState<'a> { .get(&id) .copied() .unwrap_or((0.0, 0.0)); - clip.x += sticky.0 - self.scroll.0; - clip.y += sticky.1 - self.scroll.1; + clip.translate( + sticky.0 - self.scroll.0, + sticky.1 - self.scroll.1, + ); Some(clip) } + fn descendant_clip_for( + &self, + laid: &crate::DomLayout, + id: obscura_dom::tree::NodeId, + ) -> Option { + let inherited = self.overflow_clip_for(laid, id); + let style = laid.styles.get(&id)?; + if !style.overflow_hidden || style.overflow_propagated_to_viewport { + return inherited.map(|clip| clip.viewport_rect(self.viewport)); + } + let rect = laid.rects.get(&id)?; + let (ox, oy) = self.translation_for(laid, id); + let own = crate::dom::OverflowClip::for_box(rect, style, ox, oy); + Some( + match inherited { + Some(clip) => clip.intersect(own), + None => own, + } + .viewport_rect(self.viewport), + ) + } + /// Capture-space clip for a shaped inline context. `clip_for` supplies the /// adjusted ancestor chain; text owned by an `overflow:hidden` element /// also needs that element's own padding-box clip in the same viewport @@ -1376,23 +1558,7 @@ impl<'a> ScrollPaintState<'a> { laid: &crate::DomLayout, id: obscura_dom::tree::NodeId, ) -> Option { - let inherited = self.clip_for(laid, id); - let style = laid.styles.get(&id)?; - if !style.overflow_hidden { - return inherited; - } - let rect = laid.rects.get(&id)?; - let (ox, oy) = self.translation_for(laid, id); - let own = crate::Rect { - x: rect.x + ox + style.border.left, - y: rect.y + oy + style.border.top, - width: (rect.width - style.border.left - style.border.right).max(0.0), - height: (rect.height - style.border.top - style.border.bottom).max(0.0), - }; - Some(match inherited { - Some(clip) => clip.intersect(&own).unwrap_or(crate::Rect::default()), - None => own, - }) + self.descendant_clip_for(laid, id) } } @@ -1880,6 +2046,9 @@ fn fallback_font_bytes(family: Option<&str>) -> &'static [u8] { .trim() .trim_matches(|c| c == '"' || c == '\'') .to_ascii_lowercase(); + if token == "system-ui" || token == "ui-sans-serif" { + return SYSTEM_FONT_BYTES; + } if token == "monospace" || token.contains("mono") || token.contains("courier") @@ -1905,14 +2074,12 @@ fn fallback_font_bytes(family: Option<&str>) -> &'static [u8] { || token == "arial" || token == "helvetica" || token == "helvetica neue" - || token == "system-ui" || token == "-apple-system" || token == "roboto" || token == "segoe ui" || token == "inter" || token == "verdana" || token == "tahoma" - || token == "ui-sans-serif" { return FONT_BYTES; } @@ -2288,9 +2455,14 @@ fn font_face_blocks(css: &str) -> Vec<&str> { } fn font_face_declaration<'a>(face: &'a str, name: &str) -> Option<&'a str> { - split_css_top_level(face, ';').into_iter().find_map(|declaration| { + split_css_top_level(face, ';') + .into_iter() + .find_map(|declaration| { let (property, value) = declaration.split_once(':')?; - property.trim().eq_ignore_ascii_case(name).then_some(value.trim()) + property + .trim() + .eq_ignore_ascii_case(name) + .then_some(value.trim()) }) } @@ -2575,6 +2747,129 @@ fn paint_linear_gradient( } } +fn paint_linear_gradient_layer( + pixmap: &mut Pixmap, + path: &tiny_skia::Path, + rect: &crate::Rect, + angle: f32, + stops: &[([u8; 4], Option)], + stop_positions: &[Option], + repeating: bool, + em: f32, + rem: f32, + viewport: (f32, f32), + clip: Option<&tiny_skia::Mask>, +) { + if stops.len() < 2 { + return; + } + let rad = angle.to_radians(); + let dx = rad.sin(); + let dy = -rad.cos(); + let (cx, cy) = (rect.x + rect.width / 2.0, rect.y + rect.height / 2.0); + let half = (dx.abs() * rect.width + dy.abs() * rect.height) / 2.0; + let line_length = half * 2.0; + if line_length <= f32::EPSILON { + return; + } + let base_start = Point::from_xy(cx - dx * half, cy - dy * half); + let base_end = Point::from_xy(cx + dx * half, cy + dy * half); + let mut positions: Vec> = stops + .iter() + .enumerate() + .map(|(index, (_, legacy))| { + stop_positions + .get(index) + .and_then(Option::as_deref) + .and_then(|value| { + crate::style::resolve_contextual_length( + value, + em, + rem, + viewport.0 / 100.0, + viewport.1 / 100.0, + line_length, + ) + .map(|pixels| pixels / line_length) + }) + .or(*legacy) + }) + .collect(); + if positions.first().is_some_and(Option::is_none) { + positions[0] = Some(0.0); + } + let last_index = positions.len() - 1; + if positions[last_index].is_none() { + positions[last_index] = Some(1.0); + } + let mut previous = 0usize; + for index in 1..positions.len() { + let Some(mut position) = positions[index] else { + continue; + }; + let previous_position = positions[previous].unwrap_or(0.0); + position = position.max(previous_position); + positions[index] = Some(position); + let gap = index - previous; + for offset in 1..gap { + positions[previous + offset] = Some( + previous_position + (position - previous_position) * offset as f32 / gap as f32, + ); + } + previous = index; + } + let first = positions[0].unwrap_or(0.0); + let last = positions[last_index].unwrap_or(first); + if repeating && last - first <= 1e-6 { + let color = gradient_stop_color(stops, last_index); + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), clip); + return; + } + let (start, end, spread, normalize) = if repeating { + ( + Point::from_xy( + base_start.x + (base_end.x - base_start.x) * first, + base_start.y + (base_end.y - base_start.y) * first, + ), + Point::from_xy( + base_start.x + (base_end.x - base_start.x) * last, + base_start.y + (base_end.y - base_start.y) * last, + ), + SpreadMode::Repeat, + Some((first, last - first)), + ) + } else { + (base_start, base_end, SpreadMode::Pad, None) + }; + let mut gradient_stops = Vec::with_capacity(stops.len()); + let mut monotonic = 0.0f32; + for (index, _) in stops.iter().enumerate() { + let color = gradient_stop_color(stops, index); + let position = match normalize { + Some((origin, span)) => { + ((positions[index].unwrap_or(origin) - origin) / span).clamp(0.0, 1.0) + } + None => positions[index].unwrap_or(0.0).clamp(0.0, 1.0), + } + .max(monotonic); + monotonic = position; + gradient_stops.push(GradientStop::new( + position, + Color::from_rgba8(color[0], color[1], color[2], color[3]), + )); + } + if let Some(shader) = + LinearGradient::new(start, end, gradient_stops, spread, Transform::identity()) + { + let mut paint = Paint::default(); + paint.shader = shader; + paint.anti_alias = true; + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), clip); + } +} + fn paint_radial_gradient( pixmap: &mut Pixmap, path: &tiny_skia::Path, @@ -2624,6 +2919,234 @@ fn paint_radial_gradient( } } +fn paint_background_gradient_layers( + pixmap: &mut Pixmap, + path: &tiny_skia::Path, + rect: &crate::Rect, + border_radius: (f32, f32), + style: &crate::LayoutStyle, + root_font_size: f32, + viewport: (f32, f32), + clip: Option<&tiny_skia::Mask>, +) { + let layers = &style.background_gradient_layers; + let em = style.font_size.unwrap_or(16.0); + let tile_size = background_gradient_tile_size(style, rect, em, root_font_size, viewport); + let needs_tile = + (tile_size.0 - rect.width).abs() > 0.01 || (tile_size.1 - rect.height).abs() > 0.01; + if needs_tile && tile_size.0 > 0.0 && tile_size.1 > 0.0 { + let width = tile_size.0.ceil().clamp(1.0, 4096.0) as u32; + let height = tile_size.1.ceil().clamp(1.0, 4096.0) as u32; + if let Some(mut tile) = Pixmap::new(width, height) { + let tile_rect = crate::Rect { + x: 0.0, + y: 0.0, + width: width as f32, + height: height as f32, + }; + if let Some(tile_path) = Rect::from_xywh(0.0, 0.0, tile_rect.width, tile_rect.height) + .and_then(|rect| { + let mut builder = PathBuilder::new(); + builder.push_rect(rect); + builder.finish() + }) + { + paint_gradient_layer_stack( + &mut tile, + &tile_path, + &tile_rect, + (0.0, 0.0), + layers, + em, + root_font_size, + viewport, + None, + ); + let tile_x = rect.x + + style + .background_position + .x + .resolve(rect.width - tile_size.0); + let tile_y = rect.y + + style + .background_position + .y + .resolve(rect.height - tile_size.1); + let repeats = style.background_repeat.unwrap_or((true, true)); + if repeats == (true, true) { + let mut paint = Paint::default(); + paint.shader = Pattern::new( + tile.as_ref(), + SpreadMode::Repeat, + FilterQuality::Nearest, + 1.0, + Transform::from_translate(tile_x, tile_y), + ); + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), clip); + return; + } + + let mut owner_clip = clip.cloned().or_else(|| { + let mut mask = tiny_skia::Mask::new(pixmap.width(), pixmap.height())?; + mask.fill_path(path, FillRule::Winding, true, Transform::identity()); + Some(mask) + }); + if clip.is_some() { + if let Some(mask) = owner_clip.as_mut() { + mask.intersect_path(path, FillRule::Winding, true, Transform::identity()); + } + } + let start_x = if repeats.0 { + tile_x - ((tile_x - rect.x) / width as f32).ceil() * width as f32 + } else { + tile_x + }; + let start_y = if repeats.1 { + tile_y - ((tile_y - rect.y) / height as f32).ceil() * height as f32 + } else { + tile_y + }; + let end_x = if repeats.0 { + rect.x + rect.width + } else { + tile_x + 0.5 + }; + let end_y = if repeats.1 { + rect.y + rect.height + } else { + tile_y + 0.5 + }; + let mut y = start_y; + while y < end_y { + let mut x = start_x; + while x < end_x { + pixmap.draw_pixmap( + x.floor() as i32, + y.floor() as i32, + tile.as_ref(), + &tiny_skia::PixmapPaint::default(), + Transform::identity(), + owner_clip.as_ref(), + ); + if !repeats.0 { + break; + } + x += width as f32; + } + if !repeats.1 { + break; + } + y += height as f32; + } + return; + } + } + } + + paint_gradient_layer_stack( + pixmap, + path, + rect, + border_radius, + layers, + em, + root_font_size, + viewport, + clip, + ); +} + +fn paint_gradient_layer_stack( + pixmap: &mut Pixmap, + path: &tiny_skia::Path, + rect: &crate::Rect, + border_radius: (f32, f32), + layers: &[crate::BackgroundGradientLayer], + em: f32, + root_font_size: f32, + viewport: (f32, f32), + clip: Option<&tiny_skia::Mask>, +) { + // CSS lists the topmost background first. Paint back-to-front so every + // translucent layer composites over the layers authored after it. + for layer in layers.iter().rev() { + match layer { + crate::BackgroundGradientLayer::Linear { + angle, + stops, + stop_positions, + repeating, + } => { + paint_linear_gradient_layer( + pixmap, + path, + rect, + *angle, + stops, + stop_positions, + *repeating, + em, + root_font_size, + viewport, + clip, + ); + } + crate::BackgroundGradientLayer::Radial { center, stops } => { + paint_radial_gradient(pixmap, path, rect, *center, stops, clip); + } + crate::BackgroundGradientLayer::Conic { + angle, + center, + stops, + } => { + paint_conic_gradient(pixmap, rect, border_radius, *angle, *center, stops, clip); + } + } + } +} + +fn background_gradient_tile_size( + style: &crate::LayoutStyle, + rect: &crate::Rect, + em: f32, + rem: f32, + viewport: (f32, f32), +) -> (f32, f32) { + if matches!( + style.background_size_fit, + Some(crate::ObjectFit::Cover | crate::ObjectFit::Contain) + ) { + return (rect.width, rect.height); + } + if let Some(expression) = style.background_size_expression.as_deref() { + let components = split_background_size_components(expression); + let resolve = |value: &&str, basis: f32| { + (!value.eq_ignore_ascii_case("auto")) + .then(|| { + crate::style::resolve_contextual_length( + value, + em, + rem, + viewport.0 / 100.0, + viewport.1 / 100.0, + basis, + ) + }) + .flatten() + }; + let width = components + .first() + .and_then(|value| resolve(value, rect.width)) + .unwrap_or(rect.width); + let height = components + .get(1) + .and_then(|value| resolve(value, rect.height)) + .unwrap_or(rect.height); + return (width, height); + } + style.background_size.unwrap_or((rect.width, rect.height)) +} + fn paint_conic_gradient( pixmap: &mut Pixmap, rect: &crate::Rect, @@ -2874,7 +3397,7 @@ fn background_image_rect( explicit_size: Option<(f32, f32)>, size_expression: Option<&str>, fit: Option, - position: (f32, f32), + position: crate::BackgroundPosition, em: f32, rem: f32, viewport: (f32, f32), @@ -2939,8 +3462,8 @@ fn background_image_rect( return None; } Some(crate::Rect { - x: box_rect.x + (box_rect.width - width) * position.0, - y: box_rect.y + (box_rect.height - height) * position.1, + x: box_rect.x + position.x.resolve(box_rect.width - width), + y: box_rect.y + position.y.resolve(box_rect.height - height), width, height, }) @@ -3002,21 +3525,7 @@ fn paint_in_flow_generated_box( width: generated.rect.width, height: generated.rect.height, }; - let mut clip = scroll_state.clip_for(laid, generated.host); - if host_style.overflow_hidden { - if let Some(host_rect) = laid.rects.get(&generated.host) { - let own = crate::Rect { - x: host_rect.x + ox, - y: host_rect.y + oy, - width: host_rect.width, - height: host_rect.height, - }; - clip = Some(match clip { - Some(inherited) => inherited.intersect(&own).unwrap_or(crate::Rect::default()), - None => own, - }); - } - } + let clip = scroll_state.descendant_clip_for(laid, generated.host); let visible = match clip { Some(clip) => rect.intersect(&clip), None => Some(rect), @@ -3072,6 +3581,18 @@ fn paint_in_flow_generated_box( clip_path_mask.as_ref(), ); } + if !style.background_gradient_layers.is_empty() { + paint_background_gradient_layers( + pixmap, + &path, + &rect, + radius, + style, + root_font_size, + viewport, + clip_path_mask.as_ref(), + ); + } else { if let Some((center, stops)) = &style.background_radial_gradient { paint_radial_gradient( pixmap, @@ -3094,14 +3615,8 @@ fn paint_in_flow_generated_box( ); } if let Some((angle, stops)) = &style.background_gradient { - paint_linear_gradient( - pixmap, - &path, - &rect, - *angle, - stops, - clip_path_mask.as_ref(), - ); + paint_linear_gradient(pixmap, &path, &rect, *angle, stops, clip_path_mask.as_ref()); + } } } if let Some(mask_url) = &style.mask_image { @@ -3221,6 +3736,7 @@ fn paint_positioned_pseudo( pixmap: &mut Pixmap, style: &crate::LayoutStyle, containing_block: &crate::Rect, + static_position_rect: &crate::Rect, viewport: (f32, f32), root_font_size: f32, ancestor_clip: Option, @@ -3231,36 +3747,58 @@ fn paint_positioned_pseudo( return; } let em = style.font_size.unwrap_or(16.0); - let resolve = |dimension: crate::Dimension, basis: f32| { - match dimension.resolve(em, root_font_size, viewport.0 / 100.0, viewport.1 / 100.0) { + let resolve = |dimension: crate::Dimension, basis: f32| match dimension.resolve( + em, + root_font_size, + viewport.0 / 100.0, + viewport.1 / 100.0, + ) { crate::Dimension::Px(value) => Some(value), crate::Dimension::Percent(value) => Some(value * basis), _ => None, - } }; let top = style.inset[0].and_then(|value| resolve(value, containing_block.height)); let right = style.inset[1].and_then(|value| resolve(value, containing_block.width)); let bottom = style.inset[2].and_then(|value| resolve(value, containing_block.height)); let left = style.inset[3].and_then(|value| resolve(value, containing_block.width)); - let width = resolve(style.width, containing_block.width).or_else(|| { - Some(containing_block.width - left? - right?) - }); - let height = resolve(style.height, containing_block.height).or_else(|| { - Some(containing_block.height - top? - bottom?) - }); - let (Some(width), Some(height)) = (width, height) else { return }; + // Generated text supplies the shrink-to-fit dimensions of an absolutely + // positioned pseudo whose width and/or height is auto. Tailwind's code + // gutters use exactly this shape (`width` plus auto height); requiring two + // opposing insets previously discarded the pseudo before text painting. + let generated_item = style + .before_content + .as_deref() + .filter(|content| !content.is_empty()) + .and_then(|content| text_engine.push_generated_text(content, style)); + let generated_intrinsic = generated_item.map(|item| text_engine.measure(item, None)); + let width = resolve(style.width, containing_block.width) + .or_else(|| Some(containing_block.width - left? - right?)) + .or_else(|| generated_intrinsic.map(|size| size.0)); + let height = resolve(style.height, containing_block.height) + .or_else(|| Some(containing_block.height - top? - bottom?)) + .or_else(|| generated_intrinsic.map(|size| size.1)); + let (Some(width), Some(height)) = (width, height) else { + return; + }; if width <= 0.0 || height <= 0.0 { return; } let x = left .map(|value| containing_block.x + value) .or_else(|| right.map(|value| containing_block.x + containing_block.width - value - width)) - .unwrap_or(containing_block.x); + .unwrap_or(static_position_rect.x); let y = top .map(|value| containing_block.y + value) - .or_else(|| bottom.map(|value| containing_block.y + containing_block.height - value - height)) - .unwrap_or(containing_block.y); - let rect = crate::Rect { x, y, width, height }; + .or_else(|| { + bottom.map(|value| containing_block.y + containing_block.height - value - height) + }) + .unwrap_or(static_position_rect.y); + let rect = crate::Rect { + x, + y, + width, + height, + }; let visible = match ancestor_clip { Some(clip) => rect.intersect(&clip), None => Some(rect), @@ -3308,6 +3846,18 @@ fn paint_positioned_pseudo( clip_path_mask.as_ref(), ); } + if !style.background_gradient_layers.is_empty() { + paint_background_gradient_layers( + pixmap, + &path, + &rect, + radius, + style, + root_font_size, + viewport, + clip_path_mask.as_ref(), + ); + } else { if let Some((center, stops)) = &style.background_radial_gradient { paint_radial_gradient( pixmap, @@ -3330,14 +3880,8 @@ fn paint_positioned_pseudo( ); } if let Some((angle, stops)) = &style.background_gradient { - paint_linear_gradient( - pixmap, - &path, - &rect, - *angle, - stops, - clip_path_mask.as_ref(), - ); + paint_linear_gradient(pixmap, &path, &rect, *angle, stops, clip_path_mask.as_ref()); + } } } if let Some(mask_url) = &style.mask_image { @@ -3388,15 +3932,32 @@ fn paint_positioned_pseudo( ); } } - if let Some(content) = style.before_content.as_deref().filter(|content| !content.is_empty()) { - let Some(item) = text_engine.push_generated_text(content, style) else { return }; - let (text_width, text_height) = text_engine.measure(item, None); - let x = match style.justify_content { - Some(taffy::JustifyContent::CENTER) => rect.x + (rect.width - text_width) / 2.0, - Some(taffy::JustifyContent::FLEX_END | taffy::JustifyContent::END) => { - rect.x + rect.width - style.padding.right - text_width + if let Some(item) = generated_item { + let (text_width, text_height) = generated_intrinsic.unwrap_or_default(); + // `text-align` positions inline content within the pseudo's content + // box. It is independent of flex/grid `justify-content`: Tailwind's + // absolutely positioned line-number gutters are inline-block pseudos, + // so consulting only `justify-content` left every counter at the + // gutter's start edge even though its inherited computed alignment + // was `right`. + let x = if style.display == crate::Display::Flex { + match style.justify_content { + Some(taffy::JustifyContent::CENTER) => { + rect.x + (rect.width - text_width) / 2.0 + } + Some(taffy::JustifyContent::FLEX_END | taffy::JustifyContent::END) => { + rect.x + rect.width - style.padding.right - text_width + } + _ => rect.x + style.padding.left, + } + } else { + match style.text_align { + Some(taffy::AlignItems::CENTER) => rect.x + (rect.width - text_width) / 2.0, + Some(taffy::AlignItems::FLEX_END | taffy::AlignItems::END) => { + rect.x + rect.width - style.padding.right - text_width + } + _ => rect.x + style.padding.left, } - _ => rect.x + style.padding.left, }; let y = match style.align_items { Some(taffy::AlignItems::CENTER) => rect.y + (rect.height - text_height) / 2.0, @@ -3958,8 +4519,7 @@ fn polygon_clip_mask( rem: f32, viewport: (f32, f32), ) -> Option { - let resolve = |coordinate: crate::Dimension, basis: f32| { - match coordinate.resolve( + let resolve = |coordinate: crate::Dimension, basis: f32| match coordinate.resolve( em, rem, viewport.0 / 100.0, @@ -3968,7 +4528,6 @@ fn polygon_clip_mask( crate::Dimension::Px(value) => Some(value), crate::Dimension::Percent(value) => Some(value * basis), _ => None, - } }; let mut builder = PathBuilder::new(); for (index, &(x, y)) in polygon.points.iter().enumerate() { @@ -4172,9 +4731,8 @@ fn svg_root_attr_value_range(tag: &str, wanted: &str) -> Option<(usize, usize)> while index < bytes.len() && bytes[index].is_ascii_whitespace() { index += 1; } - let (value_start, value_end) = if index < bytes.len() - && matches!(bytes[index], b'"' | b'\'') - { + let (value_start, value_end) = + if index < bytes.len() && matches!(bytes[index], b'"' | b'\'') { let delimiter = bytes[index]; index += 1; let start = index; @@ -4214,7 +4772,7 @@ fn svg_root_attr_value_range(tag: &str, wanted: &str) -> Option<(usize, usize)> #[cfg(test)] fn serialize_svg(tree: &DomTree, root: obscura_dom::tree::NodeId) -> String { let mut buf = String::new(); - serialize_svg_node(tree, root, true, None, &mut buf); + serialize_svg_node(tree, root, true, None, None, &mut buf); buf } @@ -4226,9 +4784,17 @@ fn serialize_svg_styled( tree: &DomTree, root: obscura_dom::tree::NodeId, styles: &std::collections::HashMap, + suppress_opacity_for: Option, ) -> String { let mut buf = String::new(); - serialize_svg_node(tree, root, true, Some(styles), &mut buf); + serialize_svg_node( + tree, + root, + true, + Some(styles), + suppress_opacity_for, + &mut buf, + ); buf } @@ -4253,6 +4819,7 @@ fn serialize_svg_node( nid: obscura_dom::tree::NodeId, is_root: bool, styles: Option<&std::collections::HashMap>, + suppress_opacity_for: Option, buf: &mut String, ) { let node = match tree.get_node(nid) { @@ -4268,7 +4835,14 @@ fn serialize_svg_node( // Document/comment/PI: no tag of its own, emit only element children. None => { for child in tree.children(nid) { - serialize_svg_node(tree, child, false, styles, buf); + serialize_svg_node( + tree, + child, + false, + styles, + suppress_opacity_for, + buf, + ); } return; } @@ -4368,8 +4942,15 @@ fn serialize_svg_node( &format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2]), ); } - if let Some(opacity) = computed.opacity { - append("opacity", &opacity.to_string()); + if suppress_opacity_for == Some(nid) { + // The HTML paint layer applies this SVG root's opacity after + // resvg returns. Override both a source style declaration and + // the computed value here so the group is not faded twice. + append("opacity", "1"); + } else { + if let Some(opacity) = computed.opacity { + append("opacity", &opacity.to_string()); + } } } if !declarations.is_empty() { @@ -4383,7 +4964,14 @@ fn serialize_svg_node( } buf.push('>'); for child in tree.children(nid) { - serialize_svg_node(tree, child, false, styles, buf); + serialize_svg_node( + tree, + child, + false, + styles, + suppress_opacity_for, + buf, + ); } buf.push_str("
"##, + ); + let pixmap = paint_dom(&tree, (60.0, 60.0), None).expect("pixmap"); + let border = pixmap.pixel(1, 1).expect("border pixel"); + assert!( + border.red() > 200 && border.green() < 70 && border.blue() < 100, + "adjacent var() substitution must retain a painted red border: {border:?}" + ); + let interior = pixmap.pixel(5, 5).expect("interior pixel"); + assert!( + interior.red() > 245 && interior.green() > 245 && interior.blue() > 245, + "the border must not consume the content box: {interior:?}" + ); + } + #[test] fn percentage_border_radius_paints_circles_ellipses_and_replaced_clips() { let tree = parse_html( @@ -5267,6 +5846,102 @@ mod tests { ); } + #[test] + fn ordered_background_gradients_paint_every_translucent_layer() { + let tree = parse_html( + r#" +
+
+ "#, + ); + let pixmap = paint_dom(&tree, (100.0, 100.0), None).expect("pixmap"); + let left = pixmap.pixel(4, 4).expect("top-left gradient"); + let right = pixmap.pixel(95, 4).expect("top-right gradient"); + assert!( + left.red() > left.blue() + 80, + "the top-left radial layer must tint its own corner: {left:?}" + ); + assert!( + right.blue() > right.red() + 80, + "the later top-right radial layer must remain beneath the authored top layer: {right:?}" + ); + } + + #[test] + fn repeating_linear_gradient_tiles_at_background_size_over_ordered_layer() { + let tree = parse_html( + r#" +
+
+ "#, + ); + let pixmap = paint_dom(&tree, (40.0, 40.0), None).expect("pixmap"); + let mut dark = 0usize; + let mut red = 0usize; + for y in 0..10 { + for x in 0..10 { + let first = pixmap.pixel(x, y).expect("first tile"); + let repeated = pixmap.pixel(x + 20, y + 20).expect("repeated tile"); + assert_eq!( + first, repeated, + "background-size must establish a stable 10px tile at ({x},{y})" + ); + if first.red() < 150 { + dark += 1; + } else if first.red() > 220 && first.green() < 40 && first.blue() < 40 { + red += 1; + } + } + } + assert!(dark > 4, "the repeating hatch must paint dark stripes"); + assert!( + red > 20, + "transparent hatch gaps must reveal the ordered red layer" + ); + } + + #[test] + fn length_background_positions_select_sprite_frames() { + let sprite = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='48'%20height='24'%3E%3Crect%20width='24'%20height='24'%20fill='%23ff0000'/%3E%3Crect%20x='24'%20width='24'%20height='24'%20fill='%230000ff'/%3E%3C/svg%3E"; + let tree = parse_html(&format!( + r#" +
+
+ "# + )); + let pixmap = paint_dom(&tree, (60.0, 45.0), None).expect("pixmap"); + + let first = pixmap.pixel(12, 21).expect("first sprite frame"); + assert!( + first.red() > 200 && first.green() < 40 && first.blue() < 40, + "`background-position:0` must anchor the first frame at the start edge: {first:?}" + ); + let second = pixmap.pixel(42, 21).expect("second sprite frame"); + assert!( + second.blue() > 200 && second.red() < 40 && second.green() < 40, + "a negative pixel position must select the next sprite frame: {second:?}" + ); + let above = pixmap.pixel(12, 2).expect("vertical centering"); + assert!( + above.red() < 30 && above.green() < 30 && above.blue() < 30, + "a one-value horizontal position must default the vertical axis to center: {above:?}" + ); + } + #[test] fn negative_text_indent_clips_label_without_hiding_background_icon() { let tree = parse_html( @@ -5316,7 +5991,10 @@ mod tests { None, Some("calc(100% - 2rem) auto"), None, - (0.0, 0.5), + crate::BackgroundPosition::new( + crate::BackgroundPositionAxis::percentage(0.0), + crate::BackgroundPositionAxis::percentage(0.5), + ), 10.0, 10.0, (1280.0, 720.0), @@ -5836,6 +6514,250 @@ mod tests { assert_eq!(selected_option_label(&tree, select).as_deref(), Some("Dark")); } + #[test] + fn authored_combobox_button_paints_css_math_border() { + let tree = parse_html( + r#" +
+ +
+ "#, + ); + let laid = crate::layout_dom(&tree, (320.0, 80.0)); + let language = tree.get_element_by_id("language").unwrap(); + let rect = laid.rects[&language]; + let style = &laid.styles[&language]; + assert_eq!((rect.width, rect.height), (260.0, 40.0)); + assert_eq!( + style.border, + crate::Edges { + top: 1.0, + right: 1.0, + bottom: 1.0, + left: 1.0, + } + ); + + let pixmap = paint_dom(&tree, (320.0, 80.0), None).expect("pixmap"); + let painted_top_edge = (20..240) + .filter(|&x| pixmap.pixel(x, 0).is_some_and(|pixel| pixel.alpha() > 0)) + .count(); + assert!( + painted_top_edge > 200, + "the rounded authored border should paint across the control: {painted_top_edge}" + ); + } + + #[test] + fn fractional_opacity_composites_boxes_and_inline_svg_once() { + let tree = parse_html( + r#" +
+ + + + "#, + ); + let pixmap = paint_dom(&tree, (60.0, 30.0), None).expect("pixmap"); + for (label, x) in [("box", 10), ("svg", 40)] { + let pixel = pixmap.pixel(x, 10).expect("pixel"); + assert!( + (240..=244).contains(&pixel.red()) + && pixel.red() == pixel.green() + && pixel.green() == pixel.blue(), + "{label} opacity:.05 should composite black once over white: {pixel:?}" + ); + } + } + + #[test] + fn opacity_is_applied_to_overlapping_children_as_one_group() { + let tree = parse_html( + r#" +
+
+
+
+ "#, + ); + let pixmap = paint_dom(&tree, (40.0, 30.0), None).expect("pixmap"); + let samples = [5, 15, 25].map(|x| pixmap.pixel(x, 10).unwrap().red()); + assert!( + samples.iter().all(|channel| (126..=129).contains(channel)), + "single-child and overlap regions must receive the same group alpha: {samples:?}" + ); + } + + #[test] + fn nested_opacity_groups_multiply_at_composite_boundaries() { + let tree = parse_html( + r#" +
+
+
+ "#, + ); + let pixmap = paint_dom(&tree, (30.0, 30.0), None).expect("pixmap"); + let pixel = pixmap.pixel(10, 10).expect("pixel"); + assert!( + (190..=193).contains(&pixel.red()) + && pixel.red() == pixel.green() + && pixel.green() == pixel.blue(), + "nested .5 groups should produce .25 black over white: {pixel:?}" + ); + } + + #[test] + fn opacity_group_contains_z_order_and_preserves_clip_transforms() { + let tree = parse_html( + r#" +
+
+
+
+
+
+
+ "#, + ); + let pixmap = paint_dom(&tree, (60.0, 30.0), None).expect("pixmap"); + let clipped_out = pixmap.pixel(5, 10).unwrap(); + let clipped_in = pixmap.pixel(15, 10).unwrap(); + let outside = pixmap.pixel(25, 10).unwrap(); + assert_eq!( + (clipped_out.red(), clipped_out.green(), clipped_out.blue()), + (255, 255, 255) + ); + assert!( + clipped_in.red() > 240 && (126..=129).contains(&clipped_in.green()), + "translated child should be clipped then group-composited: {clipped_in:?}" + ); + assert_eq!( + (outside.red(), outside.green(), outside.blue()), + (255, 255, 255) + ); + let covered = pixmap.pixel(40, 10).unwrap(); + assert!( + covered.blue() > 240 && covered.red() < 20, + "high-z descendant must stay inside the earlier opacity group: {covered:?}" + ); + } + + #[test] + fn overflow_clipping_keeps_physical_axes_independent() { + let tree = parse_html( + r#" +
+
+
+
+
+
+ "#, + ); + let pixmap = paint_dom(&tree, (90.0, 50.0), None).expect("pixmap"); + + let x_inside_y_outside = pixmap.pixel(15, 35).unwrap(); + assert!( + x_inside_y_outside.red() > 240 && x_inside_y_outside.blue() < 20, + "overflow-x:clip must not clip a Brave-shaped absolute child on Y: \ + {x_inside_y_outside:?}" + ); + assert_eq!( + pixmap.pixel(25, 35).map(|p| (p.red(), p.green(), p.blue())), + Some((255, 255, 255)), + "overflow-x:clip must still clip X" + ); + + let y_inside_x_outside = pixmap.pixel(75, 15).unwrap(); + assert!( + y_inside_x_outside.blue() > 240 && y_inside_x_outside.red() < 20, + "overflow-y:clip must not clip the reciprocal X overflow: \ + {y_inside_x_outside:?}" + ); + assert_eq!( + pixmap.pixel(75, 25).map(|p| (p.red(), p.green(), p.blue())), + Some((255, 255, 255)), + "overflow-y:clip must still clip Y" + ); + } + + #[test] + fn generated_boxes_use_the_hosts_overflow_on_only_the_authored_axis() { + let tree = parse_html( + r#" +
+ + "#, + ); + let pixmap = paint_dom(&tree, (90.0, 50.0), None).expect("pixmap"); + + let positioned_y_overflow = pixmap.pixel(15, 35).unwrap(); + assert!( + positioned_y_overflow.red() > 240 && positioned_y_overflow.blue() < 20, + "positioned pseudo must remain visible outside Y for overflow-x:clip: \ + {positioned_y_overflow:?}" + ); + assert_eq!( + pixmap.pixel(25, 35).map(|p| (p.red(), p.green(), p.blue())), + Some((255, 255, 255)), + "positioned pseudo must be clipped on X" + ); + + let in_flow_x_overflow = pixmap.pixel(75, 10).unwrap(); + assert!( + in_flow_x_overflow.blue() > 240 && in_flow_x_overflow.red() < 20, + "in-flow pseudo must remain visible outside X for overflow-y:clip: \ + {in_flow_x_overflow:?}" + ); + assert_eq!( + pixmap.pixel(75, 25).map(|p| (p.red(), p.green(), p.blue())), + Some((255, 255, 255)), + "in-flow pseudo must be clipped on Y" + ); + } + #[test] fn later_element_paints_over_earlier() { // A blue div nested inside a red one: both cover the origin, and blue @@ -6034,6 +6956,15 @@ mod tests { mono > sans * 1.5, "monospace advances must be used for code text: sans={sans}, mono={mono}" ); + + let sample = "Build fast, responsive sites with Bootstrap"; + let system = measure_text(sample, 64.0, false, Some("system-ui, sans-serif")); + let arial = measure_text(sample, 64.0, false, Some("Arial, sans-serif")); + assert!( + system > arial * 1.08, + "Chromium's Linux system-ui face is DejaVu Sans, not the narrower \ + Liberation Sans used for Arial: system={system}, arial={arial}" + ); } #[test] @@ -6115,7 +7046,7 @@ mod tests { ); let svg = tree.query_selector("svg").unwrap().unwrap(); let layout = crate::dom::layout_dom(&tree, (160.0, 80.0)); - let markup = serialize_svg_styled(&tree, svg, &layout.styles); + let markup = serialize_svg_styled(&tree, svg, &layout.styles, None); assert!( markup.contains("fill:#00cc55!important"), "computed author fill must cross the standalone boundary: {markup}" @@ -6360,7 +7291,7 @@ mod tests { ); let laid = crate::dom::layout_dom(&tree, (100.0, 100.0)); let svg = tree.query_selector("#icon").unwrap().unwrap(); - let markup = serialize_svg_styled(&tree, svg, &laid.styles); + let markup = serialize_svg_styled(&tree, svg, &laid.styles, None); assert!( !markup.to_ascii_lowercase().contains("light-dark("), "unsupported CSS Color 5 syntax must not reach usvg: {markup}" diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index b430c6a..aa5e695 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -119,6 +119,18 @@ pub fn ua_style(tag: &str) -> LayoutStyle { 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 @@ -445,6 +457,89 @@ fn parse_container_shorthand(value: &str) -> Option<(Vec, crate::Contain } } +#[derive(Clone, Copy)] +struct ParsedOverflowAxis { + specified: u8, + inherit: bool, +} + +fn parse_overflow_axis(value: &str) -> Option { + 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, Option)> { + 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> { let mut input = cssparser::ParserInput::new(value.trim()); let mut parser = cssparser::Parser::new(&mut input); @@ -653,6 +748,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "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; } @@ -811,12 +907,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { // so leave the prior cascade winner untouched in that case. if !value.trim().is_empty() { style.background_color = None; - style.background_gradient = - parse_linear_gradient(value, style.color_scheme_dark); - style.background_radial_gradient = - parse_radial_gradient(value, style.color_scheme_dark); - style.background_conic_gradient = - parse_conic_gradient(value, style.color_scheme_dark); + set_background_gradients(style, value); if style.background_gradient.is_none() && style.background_radial_gradient.is_none() && style.background_conic_gradient.is_none() @@ -828,17 +919,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.background_size = None; style.background_size_expression = background_size_expression(value); style.background_size_fit = parse_background_size_fit(value); - style.background_position = (0.0, 0.0); + style.background_position = crate::BackgroundPosition::default(); + style.background_repeat = parse_image_repeat(value); style.background_clip_text = false; } } "background-image" => { - style.background_gradient = - parse_linear_gradient(value, style.color_scheme_dark); - style.background_radial_gradient = - parse_radial_gradient(value, style.color_scheme_dark); - style.background_conic_gradient = - parse_conic_gradient(value, style.color_scheme_dark); + set_background_gradients(style, value); style.background_image = parse_url(value); } "background-size" => { @@ -848,6 +935,9 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { 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. @@ -857,13 +947,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.mask_size = parse_background_size(value) } "mask-repeat" | "-webkit-mask-repeat" => { - style.mask_repeat = match value.trim() { - "repeat" | "space" | "round" => Some((true, true)), - "repeat-x" => Some((true, false)), - "repeat-y" => Some((false, true)), - "no-repeat" => Some((false, false)), - _ => None, - } + style.mask_repeat = parse_image_repeat(value); } "background-clip" | "-webkit-background-clip" => { // `text` clips the background to the element's glyphs (the gradient/ @@ -1033,15 +1117,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.align_content = Some(align); style.justify_content = Some(justify); } - }, - "flex-direction" => { - match value { + } + "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 { @@ -1060,8 +1142,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "flex-basis" => { style.flex_basis = dimension_value(value.trim()); } "flex" => parse_flex_shorthand(style, value), - "position" => { - match value { + "position" => match value { "absolute" => { style.position = Some(taffy::Position::Absolute); style.position_fixed = false; @@ -1088,30 +1169,58 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.position_sticky = false; } _ => {} - } }, - "float" => { - match value { + "float" => match value { "left" => style.float = Some(crate::Float::Left), "right" => style.float = Some(crate::Float::Right), "none" => style.float = None, _ => {} - } }, - "object-fit" => { - match value.trim().to_ascii_lowercase().as_str() { + "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); @@ -1128,9 +1237,19 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { set_inset_side(style, 3, l); } "overflow" | "overflow-x" | "overflow-y" => { - style.overflow_hidden = value != "visible"; - style.overflow_scroll_container = - !matches!(value, "visible" | "clip"); + 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(); @@ -1279,13 +1398,8 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { 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::() - .ok() - .map(|number| { - crate::LineHeight::Relative(crate::Dimension::Percent( - number / 100.0, - )) + pct.trim().parse::().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) @@ -1432,6 +1546,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "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, @@ -1550,6 +1665,7 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "background-image" | "background-size" | "background-position" + | "background-repeat" | "background-clip" | "-webkit-background-clip" | "mask-image" @@ -1600,12 +1716,21 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "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" @@ -1655,6 +1780,7 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "grid-row-end" | "transform" | "transform-origin" + | "translate" | "scale" | "filter" | "backdrop-filter" @@ -1742,6 +1868,9 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { value.to_ascii_lowercase().as_str(), "auto" | "stable" | "stable both-edges" ), + "overflow" | "overflow-x" | "overflow-y" => { + parse_overflow_declaration(&name, value).is_some() + } "color" | "-webkit-text-fill-color" | "background-color" | "border-color" => { parse_color(value).is_some() } @@ -2360,6 +2489,54 @@ fn parse_individual_scale(style: &mut LayoutStyle, value: &str) { 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") @@ -2985,10 +3162,7 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { 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::().ok()) { - return parse_color_for_scheme( - s[..idx].trim(), - dark_scheme, - ) + return parse_color_for_scheme(s[..idx].trim(), dark_scheme) .map(|c| (c, Some(p / 100.0))); } } @@ -3821,6 +3995,62 @@ fn set_inset_side(style: &mut LayoutStyle, index: usize, value: &str) { } } +/// 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> { + 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::().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 { @@ -3977,7 +4207,9 @@ fn parse_column_count(value: &str) -> Option { 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"] + || [ + "px", "pt", "pc", "in", "cm", "mm", "rem", "em", "ex", "vw", "vh", "vmin", "vmax", + ] .iter() .any(|unit| lower.contains(unit)) } @@ -4231,7 +4463,16 @@ fn parse_clip_path_polygon(value: &str) -> Option { /// Parse every length token in a value as px (for box shorthands). fn edges(value: &str) -> Option { - let dims: Vec = value.split_whitespace().filter_map(px_value).collect(); + // 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 = split_ws_paren(value) + .into_iter() + .map(px) + .collect::>>()?; edges_from(dims) } @@ -4420,7 +4661,9 @@ fn px_value(tok: &str) -> Option { n = n.trim_end_matches(|c: char| c.is_ascii_alphabetic()); } - if n.chars().any(|c| !(c.is_ascii_digit() || c == '.' || c == '-')) { + if n.chars() + .any(|c| !(c.is_ascii_digit() || c == '.' || c == '-')) + { return None; } n.parse::().ok().map(|v| v * scale) @@ -4430,18 +4673,74 @@ 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 { + 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)>, + stop_positions: Vec>, + 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 ` 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<(f32, Vec<([u8; 4], Option)>)> { +fn parse_linear_gradient(value: &str, dark_scheme: bool) -> Option { 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. @@ -4506,25 +4805,39 @@ fn parse_linear_gradient( stop_start = 1; } let mut stops: Vec<([u8; 4], Option)> = Vec::new(); + let mut stop_positions = Vec::new(); for p in &parts[stop_start..] { let t = p.trim(); if t.is_empty() { continue; } - // "color [pos%]" - the color may itself contain spaces (rgb( ... )) so - // split off a trailing percentage token if present. - let (color_str, pos) = if let Some(idx) = t.rfind(char::is_whitespace) { - let tail = t[idx + 1..].trim(); - if let Some(pct) = tail.strip_suffix('%').and_then(|s| s.parse::().ok()) { - (t[..idx].trim(), Some((pct / 100.0).clamp(0.0, 1.0))) - } else { - (t, None) + 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 { - (t, None) - }; - if let Some(c) = parse_color_for_scheme(color_str, dark_scheme) { - stops.push((c, pos)); + for position in positions { + let percentage = position + .strip_suffix('%') + .and_then(|number| number.parse::().ok()) + .map(|percentage| percentage / 100.0); + stops.push((color, percentage)); + stop_positions.push(Some(position.trim().to_string())); + } + } } } if stops.len() < 2 { @@ -4532,7 +4845,30 @@ fn parse_linear_gradient( // to background_color instead (return None so parse_color runs). return None; } - Some((angle, stops)) + 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::().ok()) + .is_some_and(f32::is_finite) + }) } fn parse_radial_gradient( @@ -4556,13 +4892,7 @@ fn parse_radial_gradient( .map(|(_, coords)| coords) .or_else(|| prelude.strip_prefix("at ")) .unwrap_or_default(); - let mut coords = coords.split_whitespace(); - if let Some(x) = coords.next().and_then(percent_fraction) { - center.0 = x; - } - if let Some(y) = coords.next().and_then(percent_fraction) { - center.1 = y; - } + center = parse_gradient_center(coords); stop_start = 1; } else if parse_color_for_scheme(parts[0].trim(), dark_scheme).is_none() { stop_start = 1; @@ -4577,6 +4907,28 @@ fn parse_radial_gradient( (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. @@ -4854,6 +5206,31 @@ fn parse_background_size_fit(value: &str) -> Option { } } +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 { let (_, size) = value.rsplit_once('/')?; let mut depth = 0i32; @@ -4873,36 +5250,143 @@ fn background_size_expression(value: &str) -> Option { (!size.is_empty()).then(|| size.to_string()) } -/// `background-position` keywords/lengths -> a 0.0-1.0 fraction per axis (the -/// fraction of the box's leftover space, after the image's own size, to -/// offset by: 0 = start edge, 1 = end edge, 0.5 = centered). +/// Parse the first layer of `background-position`. /// -/// `left`/`right` always set the x axis and `top`/`bottom` always set y, -/// regardless of order (CSS allows `center right` and `right center` -/// interchangeably for keyword-only values). Any axis left unset by an -/// explicit keyword defaults to centered, which correctly handles a bare -/// `center` (both axes), a single directional keyword (`right` -> the other -/// axis centers), and explicit percentages (first fills x, second fills y, -/// per CSS shorthand order). -fn parse_background_position(value: &str) -> (f32, f32) { +/// 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::().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; - for tok in value.split_whitespace() { - match tok { - "left" => x = Some(0.0), - "right" => x = Some(1.0), - "top" => y = Some(0.0), - "bottom" => y = Some(1.0), - t if t.ends_with('%') => { - if let Ok(pct) = t.trim_end_matches('%').parse::() { - let v = pct / 100.0; - if x.is_none() { x = Some(v); } else { y = Some(v); } + 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); } - _ => {} // "center" (or an unhandled bare length): leaves this axis to default below + 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)) } } - (x.unwrap_or(0.5), y.unwrap_or(0.5)) } /// Parse a `box-shadow` value into its first layer: @@ -5195,6 +5679,16 @@ mod tests { 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] @@ -5630,6 +6124,42 @@ mod tests { 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")); @@ -5675,6 +6205,62 @@ mod tests { 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::>() + }; + + 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( @@ -5929,11 +6515,12 @@ mod tests { 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, (0.0, 0.0)); + assert_eq!(s.background_position, crate::BackgroundPosition::default()); assert!(!s.background_clip_text); let cover = compute_style( @@ -5958,6 +6545,103 @@ mod tests { ); } + #[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( @@ -5990,6 +6674,67 @@ mod tests { 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!( diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index c5708c2..6950f2d 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -3017,3 +3017,455 @@ fn text_indent_is_inherited_and_initial_resets_to_zero() { Some(obscura_render::Dimension::Px(40.0)) ); } + +/// Modern utility frameworks emit logical insets for full-width fixed bars +/// (`inset-x-0` in Tailwind is `inset-inline:0`). Both inline edges constrain +/// an auto-width positioned box, so it must stretch across the viewport. +#[test] +fn logical_inline_insets_stretch_a_fixed_auto_width_box() { + let tree = parse_html( + r#" + +
Brand
+ "#, + ); + let layout = layout_dom(&tree, (1440.0, 1000.0)); + let bar = layout.rects[&tree.get_element_by_id("bar").unwrap()]; + + assert_eq!(bar.x, 0.0, "{bar:?}"); + assert_eq!(bar.y, 0.0, "{bar:?}"); + assert_eq!(bar.width, 1440.0, "{bar:?}"); + assert_eq!(bar.height, 56.0, "{bar:?}"); +} + +#[test] +fn flex_automatic_minimum_uses_overflow_on_the_matching_axis() { + let tree = parse_html( + r#" + +
+
+ "#, + ); + let layout = layout_dom(&tree, (200.0, 100.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + + assert_eq!( + rect("block-clipped").width, + 100.0, + "block-axis overflow must not remove the inline automatic minimum" + ); + assert_eq!( + rect("inline-clipped").width, + 40.0, + "inline-axis non-visible overflow allows the flex item to shrink" + ); +} + +fn tailwind_line_counter_fixture() -> obscura_dom::tree::DomTree { + parse_html( + r#" + + + alpha + beta + gamma + + "#, + ) +} + +#[test] +fn css_counters_resolve_generated_line_numbers_in_tree_order() { + let tree = tailwind_line_counter_fixture(); + let layout = layout_dom(&tree, (120.0, 80.0)); + for (id, expected) in [("line-one", "1"), ("line-two", "2"), ("line-three", "3")] { + let style = &layout.styles[&tree.get_element_by_id(id).unwrap()]; + assert_eq!( + style + .before_pseudo + .as_ref() + .and_then(|pseudo| pseudo.before_content.as_deref()), + Some(expected), + "{id}" + ); + } +} + +#[test] +fn nested_counter_scopes_feed_counters_function_and_expire_at_parent() { + let tree = parse_html( + r#" + +
    +
  1. one +
    1. nested
    +
  2. +
  3. two
  4. +
+ "#, + ); + let layout = layout_dom(&tree, (400.0, 200.0)); + let generated = |id| { + layout.styles[&tree.get_element_by_id(id).unwrap()] + .before_content + .clone() + }; + + assert_eq!(generated("outer-one").as_deref(), Some("1 ")); + assert_eq!(generated("inner-one").as_deref(), Some("1.1 ")); + assert_eq!(generated("outer-two").as_deref(), Some("2 ")); +} + +#[cfg(feature = "paint")] +#[test] +fn generated_counter_text_reaches_the_positioned_pseudo_paint_path() { + let tree = tailwind_line_counter_fixture(); + let pixmap = + obscura_render::paint_dom(&tree, (120.0, 80.0), None).expect("counter fixture paints"); + for (line, y_range) in [(1, 0..20), (2, 20..40), (3, 40..60)] { + let dark_gutter_pixels = y_range + .flat_map(|y| (0..20).map(move |x| (x, y))) + .filter(|&(x, y)| { + let pixel = pixmap.pixel(x, y).expect("inside pixmap"); + pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 + }) + .count(); + assert!( + dark_gutter_pixels > 2, + "generated line {line} should paint at its own static block-axis position" + ); + } +} + +#[cfg(feature = "paint")] +#[test] +fn positioned_counter_inside_nested_inline_code_uses_the_code_gutter() { + let tree = parse_html( + r#" + +
+
<div>
+            <span>text</span>
+            </div>
+
+ "#, + ); + let pixmap = obscura_render::paint_dom(&tree, (260.0, 100.0), None) + .expect("nested Tailwind-shaped code fixture paints"); + let red_ink = |x_range: std::ops::Range| { + (0..72) + .flat_map(|y| x_range.clone().map(move |x| (x, y))) + .filter(|&(x, y)| { + let pixel = pixmap.pixel(x, y).expect("inside pixmap"); + pixel.red() > 160 && pixel.green() < 100 && pixel.blue() < 100 + }) + .count() + }; + assert!( + red_ink(12..36) > 4, + "line counters must paint in the positioned code gutter" + ); + assert_eq!( + red_ink(45..72), + 0, + "line counters must not overpaint the margin-shifted source text" + ); +} + +#[cfg(feature = "paint")] +#[test] +fn positioned_generated_text_honors_text_alignment_inside_its_box() { + let tree = parse_html( + r#" + +
+
+
+
+
+
+ "#, + ); + let pixmap = + obscura_render::paint_dom(&tree, (80.0, 144.0), None).expect("aligned counters paint"); + let ink_bounds = |row: u32| { + let mut min_x = u32::MAX; + let mut max_x = 0; + for y in row * 24..row * 24 + 20 { + for x in 0..24 { + let pixel = pixmap.pixel(x, y).expect("inside pixmap"); + if pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 { + min_x = min_x.min(x); + max_x = max_x.max(x); + } + } + } + assert_ne!(min_x, u32::MAX, "row {row} should contain generated text"); + (min_x, max_x) + }; + + let right_one = ink_bounds(0); + let right_ten = ink_bounds(1); + let center_one = ink_bounds(2); + let center_ten = ink_bounds(3); + let left_one = ink_bounds(4); + let left_ten = ink_bounds(5); + + assert!( + right_one.1.abs_diff(right_ten.1) <= 1, + "right-aligned 1 and 10 should share an end edge: {right_one:?} {right_ten:?}" + ); + assert!( + left_one.0.abs_diff(left_ten.0) <= 1, + "left-aligned 1 and 10 should share a start edge: {left_one:?} {left_ten:?}" + ); + assert!( + (center_one.0 + center_one.1).abs_diff(center_ten.0 + center_ten.1) <= 2, + "center-aligned 1 and 10 should share a center: {center_one:?} {center_ten:?}" + ); + assert!( + left_one.0 < center_one.0 && center_one.0 < right_one.0, + "single-digit ink should move across the pseudo box: \ + left={left_one:?} center={center_one:?} right={right_one:?}" + ); +} + +/// A percentage block size on an in-flow child resolves against the +/// containing block's definite content box. Floating the containing block +/// changes its outer inline participation, not that block-axis percentage +/// basis. +#[test] +fn percentage_height_inside_floated_and_inline_block_controls_uses_content_box() { + let tree = parse_html( + r#" + +
+ +
+ "#, + ); + let layout = layout_dom(&tree, (400.0, 200.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + + assert_eq!(rect("floated").height, 44.0); + assert_eq!(rect("inline").height, 44.0); + assert_eq!( + rect("float-icon").height, + 42.0, + "a floated containing block retains its definite content-box basis" + ); + assert_eq!( + rect("inline-icon").height, + 42.0, + "border-box padding must not leak into the child percentage basis" + ); +} + +/// A percentage height computes to auto when its containing block's height is +/// content-dependent. The resulting post-layout height must not be recycled as +/// a new percentage basis on a repair pass. +#[test] +fn percentage_height_chain_under_auto_height_atomic_parent_stays_indefinite() { + let tree = parse_html( + r#" + +
+
+ "#, + ); + let layout = layout_dom(&tree, (400.0, 200.0)); + let id = |name| tree.get_element_by_id(name).unwrap(); + + for name in ["floated", "inline", "float-icon", "inline-icon"] { + assert_eq!( + layout.styles[&id(name)].height, + obscura_render::Dimension::Auto, + "{name} must retain an indefinite block-axis percentage basis" + ); + } + assert_eq!(layout.rects[&id("float-icon")].height, 13.0); + assert_eq!(layout.rects[&id("inline-icon")].height, 13.0); +} + +#[test] +fn fit_content_width_shrink_wraps_a_grid_item_instead_of_stretching() { + let tree = parse_html( + r#" + + + + "#, + ); + let layout = layout_dom(&tree, (800.0, 200.0)); + let id = tree.get_element_by_id("cta").unwrap(); + let rect = layout.rects[&id]; + + assert!( + layout.styles[&id].width_fit_content, + "the intrinsic keyword must survive the computed-style translation" + ); + assert!( + rect.width > 80.0 && rect.width < 200.0, + "a short CTA should shrink-wrap within its grid area, not stretch to 500px: {rect:?}" + ); +} + +#[test] +fn fit_content_width_uses_available_space_and_wraps_breakable_text() { + let tree = parse_html( + r#" + +
+
several breakable words need wrapping here
+
+ + "#, + ); + let layout = layout_dom(&tree, (500.0, 200.0)); + let rect = layout.rects[&tree.get_element_by_id("fit").unwrap()]; + + assert!( + (rect.width - 140.0).abs() < 0.1, + "fit-content should clamp max-content to the containing block: {rect:?}" + ); + assert!( + rect.height >= 40.0, + "the clamped fit-content box must reflow its breakable text: {rect:?}" + ); +} + +#[test] +fn fit_content_width_keeps_an_unbreakable_min_content_floor() { + let tree = parse_html( + r#" + +
+
supercalifragilisticexpialidocious
+
+ + "#, + ); + let layout = layout_dom(&tree, (500.0, 200.0)); + let rect = layout.rects[&tree.get_element_by_id("fit").unwrap()]; + + assert!( + rect.width > 60.0, + "min-content must win when an unbreakable token is wider than the available space: {rect:?}" + ); +}