diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 76e8185..cbac674 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -24,6 +24,10 @@ struct Rule { /// An indexed set of author rules ready for fast per-element matching. pub struct Stylesheet { rules: Vec, + /// Final declarations from `@keyframes name { ... 100%/to { ... } }`. + /// Static screenshots are taken after the requested settle interval, so a + /// finite forwards-filled animation contributes this declaration block. + keyframe_ends: HashMap, by_id: HashMap>, by_class: HashMap>, by_local: HashMap>, @@ -59,6 +63,7 @@ impl Stylesheet { ) -> Self { let mut sheet = Stylesheet { rules: Vec::new(), + keyframe_ends: HashMap::new(), by_id: HashMap::new(), by_class: HashMap::new(), by_local: HashMap::new(), @@ -68,6 +73,9 @@ impl Stylesheet { }; let mut order = 0usize; for src in sources { + for (name, decls) in extract_keyframe_end_states(src) { + sheet.keyframe_ends.insert(name, decls); + } for (selector, decls) in parse_stylesheet_for_viewport(src, viewport) { let sel_trim = selector.trim(); if let Some(base) = strip_pseudo_element(sel_trim, "before") { @@ -219,6 +227,21 @@ impl Stylesheet { } let expanded = substitute_vars(&inline_normal, props, 0); crate::style::apply_declarations(style, &expanded); + + // CSS animations form a cascade origin above normal author rules and + // below author !important. A static renderer has no compositor clock; + // after page settling, the useful deterministic state for a finite + // forwards/both animation is its final keyframe. Infinite animations + // intentionally keep their ordinary computed values. + if style.animation_fill_forwards && !style.animation_iteration_infinite { + if let Some(name) = style.animation_name.as_deref() { + if let Some(decls) = self.keyframe_ends.get(name) { + let expanded = substitute_vars(decls, props, 0); + crate::style::apply_declarations(style, &expanded); + } + } + } + for &(_, _, i) in &matched { let expanded = substitute_vars(&self.rules[i].important_decls, props, 0); crate::style::apply_declarations(style, &expanded); @@ -229,6 +252,85 @@ impl Stylesheet { } } +/// Collect the declaration block for each keyframes rule's `to`/`100%` stop. +/// This is deliberately independent of selector parsing: keyframe selectors +/// are percentages, not DOM selectors, and must never enter the rule index. +fn extract_keyframe_end_states(css: &str) -> Vec<(String, String)> { + let lower = css.to_ascii_lowercase(); + let mut found = Vec::new(); + let mut cursor = 0usize; + + while cursor < css.len() { + let standard = lower[cursor..].find("@keyframes").map(|offset| { + (cursor + offset, "@keyframes".len()) + }); + let webkit = lower[cursor..].find("@-webkit-keyframes").map(|offset| { + (cursor + offset, "@-webkit-keyframes".len()) + }); + let Some((start, keyword_len)) = (match (standard, webkit) { + (Some(a), Some(b)) => Some(if a.0 <= b.0 { a } else { b }), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }) else { + break; + }; + + let after_keyword = start + keyword_len; + let Some(open_rel) = css[after_keyword..].find('{') else { break }; + let open = after_keyword + open_rel; + let name = css[after_keyword..open].trim(); + if name.is_empty() { + cursor = open + 1; + continue; + } + + let mut depth = 1i32; + let mut in_quote: Option = None; + let mut escaped = false; + let mut close = None; + for (offset, ch) in css[open + 1..].char_indices() { + if let Some(quote) = in_quote { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == quote { + in_quote = None; + } + continue; + } + if ch == '"' || ch == '\'' { + in_quote = Some(ch); + } else if ch == '{' { + depth += 1; + } else if ch == '}' { + depth -= 1; + if depth == 0 { + close = Some(open + 1 + offset); + break; + } + } + } + let Some(close) = close else { break }; + let inner = &css[open + 1..close]; + let mut final_decls = None; + for (selector, decls) in parse_stylesheet_for_viewport(inner, (1280.0, 720.0)) { + if selector + .split(',') + .any(|part| matches!(part.trim().to_ascii_lowercase().as_str(), "to" | "100%")) + { + final_decls = Some(decls); + } + } + if let Some(decls) = final_decls { + found.push((name.to_string(), decls)); + } + cursor = close + 1; + } + found +} + /// Substitute every `var(--name, fallback?)` in `input` with its resolved /// value from `props` (recursively, so a token that itself references another /// token resolves; a depth cap guards against reference cycles). A missing @@ -1014,6 +1116,26 @@ fn split_media_query_list(query: &str) -> Vec<&str> { mod tests { use super::*; + #[test] + fn keyframes_extract_only_the_final_animation_state() { + let css = r#" + @keyframes dismiss { + from { opacity: 1; visibility: visible; } + 50% { opacity: .5; } + to { opacity: 0; visibility: hidden; } + } + @-webkit-keyframes slide { + 0% { transform: translateX(0); } + 100% { transform: translateX(20px); } + } + "#; + let ends: HashMap<_, _> = extract_keyframe_end_states(css).into_iter().collect(); + assert!(ends["dismiss"].contains("opacity: 0")); + assert!(ends["dismiss"].contains("visibility: hidden")); + assert!(!ends["dismiss"].contains("opacity: 1")); + assert!(ends["slide"].contains("translateX(20px)")); + } + #[test] fn media_rules_use_the_live_width_and_height() { let css = r#" diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 9a5f259..9204cfb 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -398,6 +398,13 @@ pub struct LayoutStyle { /// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the /// default of 1.0. pub opacity: Option, + /// First CSS animation name and the parts of its timing contract needed by + /// a settled, static screenshot. A finite animation with a forwards/both + /// fill mode contributes its 100% keyframe after normal declarations and + /// before author `!important`, matching the animation cascade origin. + pub animation_name: Option, + pub animation_fill_forwards: bool, + pub animation_iteration_infinite: bool, /// `vertical-align` for a table cell's content. Cells effectively default /// to `middle` in browsers (the HTML UA sheet sets it on row groups and /// cells inherit it); obscura applies it as main-axis alignment of the diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 96a7db6..72f112f 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -649,6 +649,24 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")), "opacity" => style.opacity = value.trim().parse::().ok(), + "animation" => apply_animation_shorthand(style, value), + "animation-name" => { + let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + style.animation_name = if first.is_empty() || first.eq_ignore_ascii_case("none") { + None + } else { + Some(first.to_string()) + }; + } + "animation-fill-mode" => { + let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + style.animation_fill_forwards = + first.eq_ignore_ascii_case("forwards") || first.eq_ignore_ascii_case("both"); + } + "animation-iteration-count" => { + let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + style.animation_iteration_infinite = first.eq_ignore_ascii_case("infinite"); + } "z-index" => { style.z_index = match value.trim() { "auto" | "inherit" | "initial" => None, @@ -827,6 +845,56 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } +fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) { + style.animation_name = None; + style.animation_fill_forwards = false; + style.animation_iteration_infinite = false; + + let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + if first.is_empty() || first.eq_ignore_ascii_case("none") { + return; + } + + for token in split_ws_paren(first) { + let lower = token.to_ascii_lowercase(); + let timing_keyword = matches!( + lower.as_str(), + "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" + ) || lower.starts_with("cubic-bezier(") + || lower.starts_with("steps(") + || lower.starts_with("linear("); + let time = lower + .strip_suffix("ms") + .or_else(|| lower.strip_suffix('s')) + .and_then(|number| number.parse::().ok()) + .is_some(); + let control_keyword = matches!( + lower.as_str(), + "normal" + | "reverse" + | "alternate" + | "alternate-reverse" + | "backwards" + | "running" + | "paused" + ); + if lower == "forwards" || lower == "both" { + style.animation_fill_forwards = true; + } else if lower == "infinite" { + style.animation_iteration_infinite = true; + } else if time + || timing_keyword + || control_keyword + || lower.parse::().is_ok() + || lower == "none" + { + continue; + } else if style.animation_name.is_none() { + style.animation_name = Some(token.to_string()); + } + } +} + /// Parse the common CSS Box Alignment values that taffy can represent. /// /// The outer option distinguishes an invalid declaration from `auto`, which @@ -3189,6 +3257,25 @@ mod tests { assert!(flow_root.flow_root); } + #[test] + fn animation_shorthand_preserves_settled_forward_fill_contract() { + let finite = compute_style( + "div", + Some("animation: dismiss-overlay .6s ease-out forwards"), + ); + assert_eq!(finite.animation_name.as_deref(), Some("dismiss-overlay")); + assert!(finite.animation_fill_forwards); + assert!(!finite.animation_iteration_infinite); + + let infinite = compute_style( + "div", + Some("animation: pulse 1s linear infinite"), + ); + assert_eq!(infinite.animation_name.as_deref(), Some("pulse")); + assert!(!infinite.animation_fill_forwards); + assert!(infinite.animation_iteration_infinite); + } + #[test] fn authored_display_replaces_internal_flex_provenance() { let native_cell = compute_style("td", None); diff --git a/render-repros/animation-fill-forwards.html b/render-repros/animation-fill-forwards.html new file mode 100644 index 0000000..b3980b9 --- /dev/null +++ b/render-repros/animation-fill-forwards.html @@ -0,0 +1,34 @@ + + + + + Animation fill forwards + + + +
+
+ + + diff --git a/render-repros/checks.json b/render-repros/checks.json index f991a67..e9d2903 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -16,6 +16,9 @@ {"name": "outer static candidate is reparented after coordinate harvest", "color": "#862e9c", "x": 84, "y": 390, "width": 160, "height": 100}, {"name": "nested static candidate survives outer reparenting", "color": "#0b7285", "x": 154, "y": 413, "width": 20, "height": 20} ], + "animation-fill-forwards": [ + {"name": "finite forwards animation exposes final hidden state", "color": "#18723a", "x": 0, "y": 0, "width": 360, "height": 180} + ], "block-auto-margins": [ {"name": "two auto margins center a fixed-width block", "color": "#087f5b", "x": 300, "y": 20, "width": 300, "height": 40}, {"name": "two auto margins center a max-width block", "color": "#1971c2", "x": 200, "y": 100, "width": 500, "height": 40},