Sample CSS animation opacity at capture time

This commit is contained in:
SGavrl
2026-07-31 02:09:15 +00:00
parent bbef97dbb8
commit 1a5f0bbc7b
3 changed files with 1027 additions and 85 deletions

View File

@@ -96,6 +96,21 @@ struct PseudoRule {
container_condition_id: ContainerConditionId,
}
#[derive(Clone, Debug, PartialEq)]
struct KeyframeStop {
/// CSS keyframes always provide an offset. Keeping this optional makes the
/// normalization rule explicit and reusable by future script-created
/// keyframes without changing the sampler.
offset: Option<f32>,
declarations: String,
source_order: usize,
}
#[derive(Clone, Debug, Default, PartialEq)]
struct Keyframes {
stops: Vec<KeyframeStop>,
}
#[derive(Clone, Debug)]
pub(crate) struct ContainerBox {
pub(crate) container_type: crate::ContainerType,
@@ -468,10 +483,10 @@ pub struct Stylesheet {
rules: Vec<Rule>,
/// Index zero is the unconditional sentinel.
container_conditions: Vec<ContainerConditionNode>,
/// 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<String, String>,
/// Every offset from each `@keyframes` rule. The opacity sampler resolves
/// property-specific segments at the stylesheet's explicit sample time.
keyframes: HashMap<String, Keyframes>,
animation_sample_time: crate::AnimationSampleTime,
by_id: HashMap<String, Vec<usize>>,
by_class: HashMap<String, Vec<usize>>,
by_local: HashMap<String, Vec<usize>>,
@@ -544,6 +559,20 @@ impl Stylesheet {
tree: &DomTree,
sources: &[String],
viewport: (f32, f32),
) -> Self {
Self::parse_for_viewport_at_animation_time(
tree,
sources,
viewport,
crate::AnimationSampleTime::default(),
)
}
pub fn parse_for_viewport_at_animation_time(
tree: &DomTree,
sources: &[String],
viewport: (f32, f32),
animation_sample_time: crate::AnimationSampleTime,
) -> Self {
let mut sheet = Stylesheet {
rules: Vec::new(),
@@ -551,7 +580,8 @@ impl Stylesheet {
parent: ContainerConditionId::NONE,
alternatives: Vec::new(),
}],
keyframe_ends: HashMap::new(),
keyframes: HashMap::new(),
animation_sample_time,
by_id: HashMap::new(),
by_class: HashMap::new(),
by_local: HashMap::new(),
@@ -561,8 +591,8 @@ 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 (name, keyframes) in extract_keyframes(src) {
sheet.keyframes.insert(name, keyframes);
}
let parsed = parse_stylesheet_for_viewport_preserving_containers(
src,
@@ -969,19 +999,28 @@ impl Stylesheet {
let expanded = substitute_declarations(&inline_normal, props);
crate::style::apply_declarations_with_locked_color_scheme(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_declarations(decls, props);
crate::style::apply_declarations_with_locked_color_scheme(
style,
&expanded,
);
// Animation control properties from author !important participate in
// computed timing, while the animated value itself remains below the
// important author origin. Resolve those controls on a temporary style
// before sampling, then let the ordinary important pass override the
// sampled opacity where appropriate.
let mut animation_style = style.clone();
for &(_, _, i) in &matched {
let expanded = substitute_declarations(&self.rules[i].important_decls, props);
crate::style::apply_animation_declarations(&mut animation_style, &expanded);
}
let expanded = substitute_declarations(&inline_important, props);
crate::style::apply_animation_declarations(&mut animation_style, &expanded);
if let Some(name) = animation_style.animation_name.as_deref() {
if let Some(keyframes) = self.keyframes.get(name) {
if let Some(opacity) = sample_animation_opacity(
keyframes,
style.opacity.unwrap_or(1.0),
&animation_style.animation_timing,
self.animation_sample_time,
props,
) {
style.opacity = Some(opacity);
}
}
}
@@ -999,10 +1038,10 @@ 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)> {
/// Collect every offset and declaration block from standard and prefixed
/// keyframes rules. Keyframe selectors are percentages rather than DOM
/// selectors and must never enter the ordinary rule index.
fn extract_keyframes(css: &str) -> Vec<(String, Keyframes)> {
let lower = css.to_ascii_lowercase();
let mut found = Vec::new();
let mut cursor = 0usize;
@@ -1061,23 +1100,248 @@ fn extract_keyframe_end_states(css: &str) -> Vec<(String, String)> {
}
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);
let mut stops = Vec::new();
for (source_order, (selector, declarations)) in
parse_stylesheet_for_viewport(inner, (1280.0, 720.0))
.into_iter()
.enumerate()
{
for part in selector.split(',') {
if let Some(offset) = parse_keyframe_offset(part) {
stops.push(KeyframeStop {
offset: Some(offset),
declarations: declarations.clone(),
source_order,
});
}
}
}
if let Some(decls) = final_decls {
found.push((name.to_string(), decls));
if !stops.is_empty() {
found.push((name.to_string(), Keyframes { stops }));
}
cursor = close + 1;
}
found
}
fn parse_keyframe_offset(value: &str) -> Option<f32> {
let value = value.trim().to_ascii_lowercase();
match value.as_str() {
"from" => Some(0.0),
"to" => Some(1.0),
_ => value
.strip_suffix('%')?
.trim()
.parse::<f32>()
.ok()
.filter(|offset| offset.is_finite() && (0.0..=100.0).contains(offset))
.map(|offset| offset / 100.0),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum AnimationPhase {
Before,
Active,
After,
}
fn sample_animation_opacity(
keyframes: &Keyframes,
underlying: f32,
timing: &crate::AnimationTiming,
sample_time: crate::AnimationSampleTime,
props: &HashMap<String, String>,
) -> Option<f32> {
let progress = animation_directed_progress(timing, sample_time)?;
let normalized = normalized_keyframe_offsets(&keyframes.stops);
let mut opacity_stops = Vec::<(f32, usize, f32)>::new();
for (offset, stop) in normalized {
let expanded = substitute_declarations(&stop.declarations, props);
if let Some(opacity) = opacity_from_declarations(&expanded) {
opacity_stops.push((offset, stop.source_order, opacity));
}
}
if opacity_stops.is_empty() {
return None;
}
opacity_stops.sort_by(|left, right| {
left.0
.total_cmp(&right.0)
.then(left.1.cmp(&right.1))
});
let mut merged = Vec::<(f32, f32)>::new();
for (offset, _, opacity) in opacity_stops {
if merged.last().is_some_and(|last| last.0 == offset) {
merged.last_mut().unwrap().1 = opacity;
} else {
merged.push((offset, opacity));
}
}
if merged.first().is_none_or(|stop| stop.0 > 0.0) {
merged.insert(0, (0.0, underlying));
}
if merged.last().is_none_or(|stop| stop.0 < 1.0) {
merged.push((1.0, underlying));
}
if progress <= merged[0].0 {
return Some(merged[0].1.clamp(0.0, 1.0));
}
for pair in merged.windows(2) {
let (from_offset, from_value) = pair[0];
let (to_offset, to_value) = pair[1];
if progress <= to_offset {
if progress == to_offset || to_offset == from_offset {
return Some(to_value.clamp(0.0, 1.0));
}
let position = (progress - from_offset) / (to_offset - from_offset);
return Some((from_value + (to_value - from_value) * position).clamp(0.0, 1.0));
}
}
Some(merged.last().unwrap().1.clamp(0.0, 1.0))
}
fn opacity_from_declarations(declarations: &str) -> Option<f32> {
let mut opacity = None;
for declaration in crate::style::split_declarations(declarations) {
let Some((name, value)) = declaration.split_once(':') else { continue };
if name.trim().eq_ignore_ascii_case("opacity") {
if let Ok(parsed) = value.trim().parse::<f32>() {
if parsed.is_finite() {
opacity = Some(parsed);
}
}
}
}
opacity
}
fn animation_directed_progress(
timing: &crate::AnimationTiming,
sample_time: crate::AnimationSampleTime,
) -> Option<f32> {
let duration = timing.duration_ms.max(0.0);
let iterations = timing.iteration_count.max(0.0);
let active_duration = if duration == 0.0 || iterations == 0.0 {
0.0
} else {
duration * iterations
};
let local_time = if timing.play_state == crate::AnimationPlayState::Paused {
0.0
} else {
sample_time.milliseconds
};
if !local_time.is_finite() {
return None;
}
let end_time = (timing.delay_ms + active_duration).max(0.0);
let before_boundary = timing.delay_ms.clamp(0.0, end_time);
let after_boundary = (timing.delay_ms + active_duration).min(end_time).max(0.0);
let phase = if local_time < before_boundary {
AnimationPhase::Before
} else if local_time >= after_boundary {
AnimationPhase::After
} else {
AnimationPhase::Active
};
let active_time = match phase {
AnimationPhase::Before => {
if !matches!(
timing.fill_mode,
crate::AnimationFillMode::Backwards | crate::AnimationFillMode::Both
) {
return None;
}
(local_time - timing.delay_ms).max(0.0)
}
AnimationPhase::Active => local_time - timing.delay_ms,
AnimationPhase::After => {
if !matches!(
timing.fill_mode,
crate::AnimationFillMode::Forwards | crate::AnimationFillMode::Both
) {
return None;
}
(local_time - timing.delay_ms).clamp(0.0, active_duration)
}
};
let overall_progress = if duration == 0.0 {
if phase == AnimationPhase::Before { 0.0 } else { iterations }
} else {
active_time / duration
};
if !overall_progress.is_finite() {
return Some(match timing.direction {
crate::AnimationDirection::Reverse | crate::AnimationDirection::AlternateReverse => 1.0,
_ => 0.0,
});
}
let mut current_iteration = overall_progress.floor().max(0.0);
let mut simple_progress = overall_progress.rem_euclid(1.0);
if phase == AnimationPhase::After
&& iterations > 0.0
&& simple_progress == 0.0
{
simple_progress = 1.0;
current_iteration = (current_iteration - 1.0).max(0.0);
}
let reverse = match timing.direction {
crate::AnimationDirection::Normal => false,
crate::AnimationDirection::Reverse => true,
crate::AnimationDirection::Alternate => current_iteration.rem_euclid(2.0) >= 1.0,
crate::AnimationDirection::AlternateReverse => current_iteration.rem_euclid(2.0) < 1.0,
};
Some(if reverse {
1.0 - simple_progress
} else {
simple_progress
})
}
fn normalized_keyframe_offsets(stops: &[KeyframeStop]) -> Vec<(f32, &KeyframeStop)> {
if stops.is_empty() {
return Vec::new();
}
let mut offsets = stops.iter().map(|stop| stop.offset).collect::<Vec<_>>();
if offsets.len() == 1 {
offsets[0] = Some(offsets[0].unwrap_or(1.0));
} else {
if offsets[0].is_none() {
offsets[0] = Some(0.0);
}
let last = offsets.len() - 1;
if offsets[last].is_none() {
offsets[last] = Some(1.0);
}
}
let mut index = 0usize;
while index < offsets.len() {
if offsets[index].is_some() {
index += 1;
continue;
}
let start = index - 1;
let mut end = index + 1;
while offsets[end].is_none() {
end += 1;
}
let from = offsets[start].unwrap();
let to = offsets[end].unwrap();
let span = (end - start) as f32;
for missing in index..end {
offsets[missing] = Some(from + (to - from) * (missing - start) as f32 / span);
}
index = end + 1;
}
stops
.iter()
.zip(offsets)
.map(|(stop, offset)| (offset.unwrap(), stop))
.collect()
}
/// Resolve variables one declaration at a time. An invalid variable poisons
/// its entire declaration at computed-value time, but must not erase unrelated
/// declarations in the same rule.
@@ -2506,7 +2770,7 @@ mod tests {
}
#[test]
fn keyframes_extract_only_the_final_animation_state() {
fn keyframes_retain_every_animation_offset() {
let css = r#"
@keyframes dismiss {
from { opacity: 1; visibility: visible; }
@@ -2518,11 +2782,289 @@ mod tests {
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)"));
let keyframes: HashMap<_, _> = extract_keyframes(css).into_iter().collect();
let dismiss = normalized_keyframe_offsets(&keyframes["dismiss"].stops);
assert_eq!(
dismiss.iter().map(|(offset, _)| *offset).collect::<Vec<_>>(),
[0.0, 0.5, 1.0]
);
assert!(dismiss[0].1.declarations.contains("opacity: 1"));
assert!(dismiss[2].1.declarations.contains("visibility: hidden"));
let slide = normalized_keyframe_offsets(&keyframes["slide"].stops);
assert_eq!(
slide.iter().map(|(offset, _)| *offset).collect::<Vec<_>>(),
[0.0, 1.0]
);
assert!(slide[1].1.declarations.contains("translateX(20px)"));
}
fn sampled_animation_style(css: &str, sample_ms: f32, target_id: &str) -> LayoutStyle {
let tree = obscura_dom::parse_html(&format!(r#"<div id="{target_id}"></div>"#));
let target = tree.get_element_by_id(target_id).unwrap();
let sheet = Stylesheet::parse_for_viewport_at_animation_time(
&tree,
&[css.to_string()],
(1280.0, 720.0),
crate::AnimationSampleTime {
milliseconds: sample_ms,
},
);
let node = tree.get_node(target).unwrap();
let element = node.as_element().unwrap();
let mut matcher = tree.matcher();
let mut style = LayoutStyle::default();
sheet.apply(
&tree,
&mut matcher,
target,
node.get_attribute("id"),
&[],
element.local.as_ref(),
&mut style,
&HashMap::new(),
None,
);
style
}
fn sampled_fade(extra: &str, sample_ms: f32) -> f32 {
let css = format!(
r#"
@keyframes fade {{ from {{ opacity:0 }} to {{ opacity:1 }} }}
#target {{
opacity:.4;
animation:fade 1s linear infinite;
{extra}
}}
"#
);
sampled_animation_style(&css, sample_ms, "target")
.opacity
.unwrap_or(1.0)
}
fn assert_opacity(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 0.0001,
"expected opacity {expected}, got {actual}"
);
}
#[test]
fn animation_timeline_handles_delay_fill_iterations_and_direction_at_t0() {
assert_opacity(sampled_fade("", 0.0), 0.0);
assert_opacity(sampled_fade("animation-delay:250ms", 0.0), 0.4);
assert_opacity(
sampled_fade("animation-delay:250ms;animation-fill-mode:backwards", 0.0),
0.0,
);
assert_opacity(
sampled_fade(
"animation-delay:250ms;animation-fill-mode:backwards;animation-direction:reverse",
0.0,
),
1.0,
);
assert_opacity(sampled_fade("animation-delay:-250ms", 0.0), 0.25);
assert_opacity(sampled_fade("animation-delay:-1s", 0.0), 0.0);
assert_opacity(
sampled_fade("animation-delay:-1s;animation-direction:alternate", 0.0),
1.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:none",
0.0,
),
0.4,
);
assert_opacity(
sampled_fade(
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards",
0.0,
),
1.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards;animation-direction:reverse",
0.0,
),
0.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-2s;animation-iteration-count:2;animation-fill-mode:forwards;animation-direction:alternate",
0.0,
),
0.0,
);
assert_opacity(
sampled_fade(
"animation-iteration-count:0;animation-fill-mode:forwards",
0.0,
),
0.0,
);
assert_opacity(
sampled_fade(
"animation-duration:0s;animation-iteration-count:1;animation-fill-mode:none",
0.0,
),
0.4,
);
assert_opacity(
sampled_fade(
"animation-duration:0s;animation-iteration-count:1;animation-fill-mode:forwards",
0.0,
),
1.0,
);
assert_opacity(
sampled_fade(
"animation-delay:-2.5s;animation-iteration-count:2.5;animation-fill-mode:forwards",
0.0,
),
0.5,
);
}
#[test]
fn animation_calc_delay_pause_and_important_origin_are_deterministic() {
assert_opacity(
sampled_fade("--step:.1s;animation-delay:calc(var(--step) * -2.5)", 0.0),
0.25,
);
assert_opacity(sampled_fade("animation-play-state:paused", 500.0), 0.0);
assert_opacity(sampled_fade("", 500.0), 0.5);
assert_opacity(
sampled_fade(
"animation-delay:-250ms !important;opacity:.8 !important",
0.0,
),
0.8,
);
}
#[test]
fn opacity_segments_use_underlying_endpoints_and_exact_later_boundaries() {
let missing_endpoints = r#"
@keyframes middle { 50% { opacity:1 } }
#target { opacity:.4; animation:middle 1s linear 1 both }
"#;
assert_opacity(
sampled_animation_style(missing_endpoints, 250.0, "target")
.opacity
.unwrap(),
0.7,
);
assert_opacity(
sampled_animation_style(missing_endpoints, 750.0, "target")
.opacity
.unwrap(),
0.7,
);
let exact_boundary = r#"
@keyframes peak {
0% { opacity:0 }
50% { opacity:1 }
100% { opacity:0 }
}
#target { opacity:.4; animation:peak 1s linear 1 both }
"#;
assert_opacity(
sampled_animation_style(exact_boundary, 500.0, "target")
.opacity
.unwrap(),
1.0,
);
}
#[test]
fn missing_keyframe_offsets_follow_web_animation_distribution() {
let stop = |offset| KeyframeStop {
offset,
declarations: "opacity:1".to_string(),
source_order: 0,
};
let single = [stop(None)];
assert_eq!(normalized_keyframe_offsets(&single)[0].0, 1.0);
let pair = [stop(None), stop(None)];
assert_eq!(
normalized_keyframe_offsets(&pair)
.iter()
.map(|(offset, _)| *offset)
.collect::<Vec<_>>(),
[0.0, 1.0]
);
let distributed = [
stop(Some(0.0)),
stop(None),
stop(None),
stop(Some(0.75)),
stop(None),
];
assert_eq!(
normalized_keyframe_offsets(&distributed)
.iter()
.map(|(offset, _)| *offset)
.collect::<Vec<_>>(),
[0.0, 0.25, 0.5, 0.75, 1.0]
);
}
#[test]
fn mozilla_stagger_samples_only_the_delay_zero_frame() {
let mut html = String::new();
let mut selectors = String::new();
for frame in 0..12 {
html.push_str(&format!(r#"<svg id="frame{frame}" class="frame"></svg>"#));
selectors.push_str(&format!(
"#frame{frame}{{animation-delay:calc(var(--base-delay) * {frame})}}"
));
}
let tree = obscura_dom::parse_html(&html);
let css = format!(
r#"
@keyframes wave {{
0%, 8.333% {{ opacity:1 }}
8.4%, to {{ opacity:0 }}
}}
.frame {{
--base-delay:.1s;
opacity:0;
animation:wave 1.2s linear infinite;
}}
{selectors}
"#
);
let sheet = Stylesheet::parse_for_viewport_at_animation_time(
&tree,
&[css],
(1280.0, 720.0),
crate::AnimationSampleTime::default(),
);
for frame in 0..12 {
let id = format!("frame{frame}");
let target = tree.get_element_by_id(&id).unwrap();
let node = tree.get_node(target).unwrap();
let element = node.as_element().unwrap();
let classes = vec!["frame".to_string()];
let mut matcher = tree.matcher();
let mut style = LayoutStyle::default();
sheet.apply(
&tree,
&mut matcher,
target,
node.get_attribute("id"),
&classes,
element.local.as_ref(),
&mut style,
&HashMap::new(),
None,
);
assert_opacity(style.opacity.unwrap(), if frame == 0 { 1.0 } else { 0.0 });
}
}
#[test]

View File

@@ -533,13 +533,11 @@ pub struct LayoutStyle {
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
/// default of 1.0.
pub opacity: Option<f32>,
/// 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
/// First CSS animation name and its timing contract. The stylesheet
/// sampler contributes animated opacity after normal declarations and
/// before author `!important`, matching the animation cascade origin.
pub animation_name: Option<String>,
pub animation_fill_forwards: bool,
pub animation_iteration_infinite: bool,
pub animation_timing: AnimationTiming,
/// `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
@@ -809,6 +807,67 @@ pub enum WhiteSpace {
BreakSpaces,
}
/// Deterministic CSS animation sample time relative to the animation's
/// timeline origin. Screenshot rendering currently uses the initial value
/// (0ms); keeping it explicit lets capture plumbing freeze other engines at
/// the same instant instead of depending on wall-clock settling.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct AnimationSampleTime {
pub milliseconds: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationDirection {
#[default]
Normal,
Reverse,
Alternate,
AlternateReverse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationFillMode {
#[default]
None,
Forwards,
Backwards,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationPlayState {
#[default]
Running,
Paused,
}
/// Timing fields for the first CSS animation. Milliseconds are used
/// internally so `s`, `ms`, negative delays, and calculated stagger values
/// share one unit.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AnimationTiming {
pub duration_ms: f32,
pub delay_ms: f32,
/// A finite non-negative count, or positive infinity for `infinite`.
pub iteration_count: f32,
pub direction: AnimationDirection,
pub fill_mode: AnimationFillMode,
pub play_state: AnimationPlayState,
}
impl Default for AnimationTiming {
fn default() -> Self {
Self {
duration_ms: 0.0,
delay_ms: 0.0,
iteration_count: 1.0,
direction: AnimationDirection::Normal,
fill_mode: AnimationFillMode::None,
play_state: AnimationPlayState::Running,
}
}
}
/// The implemented `text-wrap-style` values. Other line-breaking strategies
/// such as `pretty` remain unsupported until their distinct scoring model is
/// available; treating them as `auto` would make `@supports` lie.

View File

@@ -314,6 +314,16 @@ pub(crate) fn apply_declarations_with_locked_color_scheme(
}
}
pub(crate) fn apply_animation_declarations(style: &mut LayoutStyle, css: &str) {
for raw in split_declarations(css) {
let Some((name, value)) = raw.trim().split_once(':') else { continue };
let name = name.trim().to_ascii_lowercase();
if name == "animation" || name.starts_with("animation-") {
apply_value(style, &name, value.trim());
}
}
}
fn apply_color_scheme(
style: &mut LayoutStyle,
value: &str,
@@ -1116,20 +1126,82 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
"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") {
style.animation_name = if first.is_empty()
|| matches!(
first.to_ascii_lowercase().as_str(),
"none" | "initial" | "inherit" | "unset" | "revert" | "revert-layer"
)
{
None
} else {
Some(first.to_string())
};
}
"animation-duration" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.duration_ms = 0.0;
} else if let Some(milliseconds) = parse_animation_time_ms(first).filter(|time| *time >= 0.0) {
style.animation_timing.duration_ms = milliseconds;
}
}
"animation-delay" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.delay_ms = 0.0;
} else if let Some(milliseconds) = parse_animation_time_ms(first) {
style.animation_timing.delay_ms = milliseconds;
}
}
"animation-fill-mode" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
style.animation_fill_forwards =
first.eq_ignore_ascii_case("forwards") || first.eq_ignore_ascii_case("both");
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.fill_mode = crate::AnimationFillMode::None;
} else if let Some(fill) = parse_animation_fill_mode(first) {
style.animation_timing.fill_mode = fill;
}
}
"animation-iteration-count" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
style.animation_iteration_infinite = first.eq_ignore_ascii_case("infinite");
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.iteration_count = 1.0;
} else if let Some(iterations) = parse_animation_iteration_count(first) {
style.animation_timing.iteration_count = iterations;
}
}
"animation-direction" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.direction = crate::AnimationDirection::Normal;
} else if let Some(direction) = parse_animation_direction(first) {
style.animation_timing.direction = direction;
}
}
"animation-play-state" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_timing.play_state = crate::AnimationPlayState::Running;
} else if let Some(play_state) = parse_animation_play_state(first) {
style.animation_timing.play_state = play_state;
}
}
"z-index" => {
style.z_index = match value.trim() {
@@ -1510,8 +1582,12 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
| "opacity"
| "animation"
| "animation-name"
| "animation-duration"
| "animation-delay"
| "animation-fill-mode"
| "animation-iteration-count"
| "animation-direction"
| "animation-play-state"
| "z-index"
| "clear"
| "vertical-align"
@@ -1606,6 +1682,12 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
"auto" | "wrap" | "balance" | "wrap balance" | "balance wrap"
),
"text-wrap-style" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "balance"),
"animation-duration" => parse_animation_time_ms(value).is_some_and(|time| time >= 0.0),
"animation-delay" => parse_animation_time_ms(value).is_some(),
"animation-iteration-count" => parse_animation_iteration_count(value).is_some(),
"animation-direction" => parse_animation_direction(value).is_some(),
"animation-fill-mode" => parse_animation_fill_mode(value).is_some(),
"animation-play-state" => parse_animation_play_state(value).is_some(),
"float" => matches!(
value.to_ascii_lowercase().as_str(),
"none" | "left" | "right"
@@ -1646,15 +1728,22 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
}
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") {
if first.is_empty() {
return;
}
if matches!(
first.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
) {
style.animation_name = None;
style.animation_timing = crate::AnimationTiming::default();
return;
}
let mut name = None;
let mut timing = crate::AnimationTiming::default();
let mut time_count = 0usize;
for token in split_ws_paren(first) {
let lower = token.to_ascii_lowercase();
let timing_keyword = matches!(
@@ -1663,36 +1752,259 @@ fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) {
) || 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::<f32>().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::<f32>().is_ok()
|| lower == "none"
{
if let Some(time) = parse_animation_time_ms(&lower) {
if time_count == 0 {
if time < 0.0 {
return;
}
timing.duration_ms = time;
} else if time_count == 1 {
timing.delay_ms = time;
} else {
return;
}
time_count += 1;
} else if let Some(direction) = parse_animation_direction(&lower) {
timing.direction = direction;
} else if let Some(fill) = parse_animation_fill_mode(&lower) {
timing.fill_mode = fill;
} else if let Some(play_state) = parse_animation_play_state(&lower) {
timing.play_state = play_state;
} else if let Some(iterations) = parse_animation_iteration_count(&lower) {
timing.iteration_count = iterations;
} else if timing_keyword {
continue;
} else if style.animation_name.is_none() {
style.animation_name = Some(token.to_string());
} else if lower == "none" {
if name.is_some() {
return;
}
} else if name.is_none() {
name = Some(token.to_string());
} else {
return;
}
}
style.animation_name = name;
style.animation_timing = timing;
}
fn parse_animation_direction(value: &str) -> Option<crate::AnimationDirection> {
match value.trim().to_ascii_lowercase().as_str() {
"normal" => Some(crate::AnimationDirection::Normal),
"reverse" => Some(crate::AnimationDirection::Reverse),
"alternate" => Some(crate::AnimationDirection::Alternate),
"alternate-reverse" => Some(crate::AnimationDirection::AlternateReverse),
_ => None,
}
}
fn parse_animation_fill_mode(value: &str) -> Option<crate::AnimationFillMode> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(crate::AnimationFillMode::None),
"forwards" => Some(crate::AnimationFillMode::Forwards),
"backwards" => Some(crate::AnimationFillMode::Backwards),
"both" => Some(crate::AnimationFillMode::Both),
_ => None,
}
}
fn parse_animation_play_state(value: &str) -> Option<crate::AnimationPlayState> {
match value.trim().to_ascii_lowercase().as_str() {
"running" => Some(crate::AnimationPlayState::Running),
"paused" => Some(crate::AnimationPlayState::Paused),
_ => None,
}
}
fn parse_animation_iteration_count(value: &str) -> Option<f32> {
let value = value.trim();
if value.eq_ignore_ascii_case("infinite") {
return Some(f32::INFINITY);
}
value.parse::<f32>().ok().filter(|count| count.is_finite() && *count >= 0.0)
}
#[derive(Clone, Copy)]
enum AnimationCalcValue {
Number(f32),
Milliseconds(f32),
}
impl AnimationCalcValue {
fn negated(self) -> Self {
match self {
Self::Number(value) => Self::Number(-value),
Self::Milliseconds(value) => Self::Milliseconds(-value),
}
}
fn add(self, other: Self, subtract: bool) -> Option<Self> {
let sign = if subtract { -1.0 } else { 1.0 };
match (self, other) {
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left + sign * right)),
(Self::Milliseconds(left), Self::Milliseconds(right)) => {
Some(Self::Milliseconds(left + sign * right))
}
_ => None,
}
}
fn multiply(self, other: Self) -> Option<Self> {
match (self, other) {
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left * right)),
(Self::Milliseconds(time), Self::Number(number))
| (Self::Number(number), Self::Milliseconds(time)) => {
Some(Self::Milliseconds(time * number))
}
_ => None,
}
}
fn divide(self, other: Self) -> Option<Self> {
match (self, other) {
(_, Self::Number(0.0)) => None,
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left / right)),
(Self::Milliseconds(time), Self::Number(number)) => {
Some(Self::Milliseconds(time / number))
}
(Self::Milliseconds(left), Self::Milliseconds(right)) if right != 0.0 => {
Some(Self::Number(left / right))
}
_ => None,
}
}
}
struct AnimationCalcParser<'a> {
input: &'a [u8],
position: usize,
}
impl AnimationCalcParser<'_> {
fn skip_whitespace(&mut self) {
while self.input.get(self.position).is_some_and(u8::is_ascii_whitespace) {
self.position += 1;
}
}
fn consume(&mut self, byte: u8) -> bool {
self.skip_whitespace();
if self.input.get(self.position) == Some(&byte) {
self.position += 1;
true
} else {
false
}
}
fn sum(&mut self) -> Option<AnimationCalcValue> {
let mut value = self.product()?;
loop {
if self.consume(b'+') {
value = value.add(self.product()?, false)?;
} else if self.consume(b'-') {
value = value.add(self.product()?, true)?;
} else {
return Some(value);
}
}
}
fn product(&mut self) -> Option<AnimationCalcValue> {
let mut value = self.unary()?;
loop {
if self.consume(b'*') {
value = value.multiply(self.unary()?)?;
} else if self.consume(b'/') {
value = value.divide(self.unary()?)?;
} else {
return Some(value);
}
}
}
fn unary(&mut self) -> Option<AnimationCalcValue> {
if self.consume(b'+') {
self.unary()
} else if self.consume(b'-') {
Some(self.unary()?.negated())
} else {
self.primary()
}
}
fn primary(&mut self) -> Option<AnimationCalcValue> {
if self.consume(b'(') {
let value = self.sum()?;
self.consume(b')').then_some(value)
} else {
self.number()
}
}
fn number(&mut self) -> Option<AnimationCalcValue> {
self.skip_whitespace();
let start = self.position;
let mut saw_digit = false;
while let Some(byte) = self.input.get(self.position) {
if byte.is_ascii_digit() {
saw_digit = true;
self.position += 1;
} else if *byte == b'.' {
self.position += 1;
} else {
break;
}
}
if !saw_digit {
return None;
}
let number = std::str::from_utf8(&self.input[start..self.position])
.ok()?
.parse::<f32>()
.ok()?;
if self.input.get(self.position..self.position + 2) == Some(b"ms") {
self.position += 2;
Some(AnimationCalcValue::Milliseconds(number))
} else if self.input.get(self.position) == Some(&b's') {
self.position += 1;
Some(AnimationCalcValue::Milliseconds(number * 1000.0))
} else {
Some(AnimationCalcValue::Number(number))
}
}
}
pub(crate) fn parse_animation_time_ms(value: &str) -> Option<f32> {
let normalized = value.trim().to_ascii_lowercase();
let is_calc = normalized.starts_with("calc(") && normalized.ends_with(')');
if !is_calc
&& normalized
.bytes()
.any(|byte| matches!(byte, b'+' | b'*' | b'/' | b'(' | b')'))
{
return None;
}
let expression = if is_calc {
&normalized[5..normalized.len() - 1]
} else {
normalized.as_str()
};
let mut parser = AnimationCalcParser {
input: expression.as_bytes(),
position: 0,
};
let result = parser.sum()?;
parser.skip_whitespace();
if parser.position != parser.input.len() {
return None;
}
match result {
AnimationCalcValue::Milliseconds(milliseconds) if milliseconds.is_finite() => {
Some(milliseconds)
}
_ => None,
}
}
/// Parse the common CSS Box Alignment values that taffy can represent.
@@ -4591,22 +4903,51 @@ mod tests {
}
#[test]
fn animation_shorthand_preserves_settled_forward_fill_contract() {
fn animation_shorthand_parses_the_first_timing_contract() {
let finite = compute_style(
"div",
Some("animation: dismiss-overlay .6s ease-out forwards"),
);
assert_eq!(finite.animation_name.as_deref(), Some("dismiss-overlay"));
assert!(finite.animation_fill_forwards);
assert!(!finite.animation_iteration_infinite);
assert_eq!(finite.animation_timing.duration_ms, 600.0);
assert_eq!(finite.animation_timing.fill_mode, crate::AnimationFillMode::Forwards);
assert_eq!(finite.animation_timing.iteration_count, 1.0);
let infinite = compute_style(
"div",
Some("animation: pulse 1s linear infinite"),
);
assert_eq!(infinite.animation_name.as_deref(), Some("pulse"));
assert!(!infinite.animation_fill_forwards);
assert!(infinite.animation_iteration_infinite);
assert_eq!(infinite.animation_timing.duration_ms, 1000.0);
assert_eq!(infinite.animation_timing.fill_mode, crate::AnimationFillMode::None);
assert!(infinite.animation_timing.iteration_count.is_infinite());
let calculated = compute_style(
"div",
Some(
"animation:wave 1.2s linear infinite;\
animation-delay:calc(.1s * -2.5);\
animation-direction:alternate-reverse;\
animation-fill-mode:both;\
animation-play-state:paused",
),
);
assert_eq!(calculated.animation_timing.delay_ms, -250.0);
assert_eq!(
calculated.animation_timing.direction,
crate::AnimationDirection::AlternateReverse
);
assert_eq!(calculated.animation_timing.fill_mode, crate::AnimationFillMode::Both);
assert_eq!(calculated.animation_timing.play_state, crate::AnimationPlayState::Paused);
assert_eq!(parse_animation_time_ms("calc(1s / 2 + 25ms)"), Some(525.0));
assert_eq!(parse_animation_time_ms("1s * 2"), None);
let reset = compute_style(
"div",
Some("animation:fade 2s -1s 3 reverse both paused;animation:initial"),
);
assert_eq!(reset.animation_name, None);
assert_eq!(reset.animation_timing, crate::AnimationTiming::default());
}
#[test]