Make rendering asynchronous and timeline-aware

This commit is contained in:
SGavrl committed 2026-08-04 02:37:03 +00:00
1 parent 68f20cf525
commit 66f04d0f43
18 files changed
+3952 -290

No files matched your search

+284 -6
View File
@@ -1290,6 +1290,19 @@ fn note_compound_dependencies(
}
}
}
"dir" | "lang" => {
// The corresponding HTML attributes inherit through
// descendants, while the functional selector may
// observe language/direction resolved above the
// mutated node. Keep the dependency keyed, but require
// the retained planner's sound full fallback.
map.push_state(
name,
rule_order,
InvalidationReaches::CONSERVATIVE,
);
map.mark_conservative(rule_order);
}
_ => {
// A compiled functional pseudo outside the explicitly
// modeled set may hide selector or document state.
@@ -1449,6 +1462,8 @@ fn note_declaration_attribute_dependencies(
fn selector_requires_conservative_tracking(selector: &str) -> bool {
let selector = selector.to_ascii_lowercase();
selector.contains(":has(")
|| selector.contains(":dir(")
|| selector.contains(":lang(")
|| selector.contains(":nth-child(")
|| selector.contains(":nth-last-child(")
|| selector.contains(":nth-of-type(")
@@ -1638,6 +1653,86 @@ impl PseudoRuleMap {
}
self.rules.push(rule);
}
fn bucket_matches_container_query_rule(
&self,
bucket: Option<&Vec<usize>>,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
bucket.is_some_and(|indices| {
indices.iter().copied().any(|index| {
let rule = &self.rules[index];
rule.container_condition_id != ContainerConditionId::NONE
&& (rule.candidate_slot == NO_CANDIDATE_SLOT
|| matcher.mark_candidate(rule.candidate_slot as usize))
&& matcher.matches(tree, nid, &rule.sel)
})
})
}
fn node_matches_container_query_rule(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
if self.candidate_slot_count != 0 {
matcher.begin_candidate_collection(self.candidate_slot_count);
}
let Some(node) = tree.get_node(nid) else {
return false;
};
node.as_element().is_some_and(|element| {
self.bucket_matches_container_query_rule(
self.by_local.get(element.local.as_ref()),
tree,
matcher,
nid,
) || (is_root_element(tree, nid)
&& self.bucket_matches_container_query_rule(
(!self.by_root.is_empty()).then_some(&self.by_root),
tree,
matcher,
nid,
))
|| node.get_attribute("id").is_some_and(|id| {
self.bucket_matches_container_query_rule(
self.by_id.get(id),
tree,
matcher,
nid,
)
})
|| node.get_attribute("class").is_some_and(|classes| {
classes.split_whitespace().any(|class| {
self.bucket_matches_container_query_rule(
self.by_class.get(class),
tree,
matcher,
nid,
)
})
})
|| node.attrs().is_some_and(|attributes| {
attributes.iter().any(|attribute| {
self.bucket_matches_container_query_rule(
self.by_attribute.get(attribute.name.local.as_ref()),
tree,
matcher,
nid,
)
})
})
|| self.bucket_matches_container_query_rule(
(!self.universal.is_empty()).then_some(&self.universal),
tree,
matcher,
nid,
)
})
}
}
/// A cascade layer's first-declaration position at each nesting level.
@@ -2330,6 +2425,98 @@ impl Stylesheet {
.any(|rule| rule.container_condition_id != ContainerConditionId::NONE)
}
/// Whether `nid` can receive a declaration from any container-conditional
/// rule in the current DOM state. Retained style uses this to reset only
/// conditional subjects (and their inherited subtrees) for its query-free
/// seed pass instead of discarding every clean computed style merely
/// because the document owns a query container.
fn bucket_matches_container_query_rule(
&self,
bucket: Option<&Vec<usize>>,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
bucket.is_some_and(|indices| {
indices.iter().copied().any(|index| {
let rule = &self.rules[index];
rule.container_condition_id != ContainerConditionId::NONE
&& (rule.candidate_slot == NO_CANDIDATE_SLOT
|| matcher.mark_candidate(rule.candidate_slot as usize))
&& matcher.matches(tree, nid, &rule.sel)
})
})
}
pub(crate) fn node_matches_container_query_rule(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
) -> bool {
if self.candidate_slot_count != 0 {
matcher.begin_candidate_collection(self.candidate_slot_count);
}
let Some(node) = tree.get_node(nid) else {
return false;
};
let normal_match = node.as_element().is_some_and(|element| {
self.bucket_matches_container_query_rule(
self.by_local.get(element.local.as_ref()),
tree,
matcher,
nid,
) || (is_root_element(tree, nid)
&& self.bucket_matches_container_query_rule(
(!self.by_root.is_empty()).then_some(&self.by_root),
tree,
matcher,
nid,
))
|| node.get_attribute("id").is_some_and(|id| {
self.bucket_matches_container_query_rule(
self.by_id.get(id),
tree,
matcher,
nid,
)
})
|| node.get_attribute("class").is_some_and(|classes| {
classes.split_whitespace().any(|class| {
self.bucket_matches_container_query_rule(
self.by_class.get(class),
tree,
matcher,
nid,
)
})
})
|| node.attrs().is_some_and(|attributes| {
attributes.iter().any(|attribute| {
self.bucket_matches_container_query_rule(
self.by_attribute.get(attribute.name.local.as_ref()),
tree,
matcher,
nid,
)
})
})
|| self.bucket_matches_container_query_rule(
(!self.universal.is_empty()).then_some(&self.universal),
tree,
matcher,
nid,
)
});
normal_match
|| self
.before_rules
.node_matches_container_query_rule(tree, matcher, nid)
|| self
.after_rules
.node_matches_container_query_rule(tree, matcher, nid)
}
pub(crate) fn container_condition_depth(&self) -> usize {
self.container_conditions
.iter()
@@ -2860,6 +3047,39 @@ impl Stylesheet {
style: &mut LayoutStyle,
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
) -> Option<HashMap<String, String>> {
let mut animation_timeline = crate::AnimationTimelineState::default();
self.apply_at_animation_time(
tree,
matcher,
nid,
id,
classes,
local,
style,
parent_props,
inline_css,
crate::AnimationSample {
time: self.animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub(crate) fn apply_at_animation_time(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
id: Option<&str>,
classes: &[String],
local: &str,
style: &mut LayoutStyle,
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<HashMap<String, String>> {
self.apply_internal(
tree,
@@ -2872,9 +3092,12 @@ impl Stylesheet {
parent_props,
inline_css,
None,
animation_sample,
animation_timeline,
)
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn apply_with_container_queries(
&self,
tree: &DomTree,
@@ -2887,6 +3110,41 @@ impl Stylesheet {
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
evaluator: &mut ContainerQueryEvaluator<'_>,
) -> Option<HashMap<String, String>> {
let mut animation_timeline = crate::AnimationTimelineState::default();
self.apply_with_container_queries_at_animation_time(
tree,
matcher,
nid,
id,
classes,
local,
style,
parent_props,
inline_css,
evaluator,
crate::AnimationSample {
time: self.animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub(crate) fn apply_with_container_queries_at_animation_time(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
id: Option<&str>,
classes: &[String],
local: &str,
style: &mut LayoutStyle,
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
evaluator: &mut ContainerQueryEvaluator<'_>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<HashMap<String, String>> {
self.apply_internal(
tree,
@@ -2899,6 +3157,8 @@ impl Stylesheet {
parent_props,
inline_css,
Some(evaluator),
animation_sample,
animation_timeline,
)
}
@@ -2914,6 +3174,8 @@ impl Stylesheet {
parent_props: &HashMap<String, String>,
inline_css: Option<&str>,
mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<HashMap<String, String>> {
// Keep the two cascade priorities separate from the outset. A typical
// stylesheet has very few important declarations, so cloning and
@@ -3265,6 +3527,7 @@ impl Stylesheet {
|| important_matched
.iter()
.any(|&(_, _, i)| self.rules[i].important_flags.has_animation);
style.animation_has_render_effect = false;
if !self.keyframes.is_empty() && (style.animation_name.is_some() || important_has_animation)
{
let mut animation_style = style.clone();
@@ -3290,15 +3553,31 @@ impl Stylesheet {
}
if let Some(name) = animation_style.animation_name.as_deref() {
if let Some(keyframes) = self.keyframes.get(name) {
style.animation_has_render_effect = !keyframes.tracks.is_empty();
let local_sample = animation_timeline.sample_for(
nid,
crate::AnimationInstanceKey {
name: name.to_string(),
},
animation_style.animation_timing.play_state,
animation_sample,
);
style.animation_local_time_ms = local_sample.milliseconds;
sample_animation_properties(
keyframes,
style,
&animation_style.animation_timing,
self.animation_sample_time,
local_sample,
props,
);
} else {
animation_timeline.clear_animation(nid, animation_sample);
}
} else {
animation_timeline.clear_animation(nid, animation_sample);
}
} else {
animation_timeline.clear_animation(nid, animation_sample);
}
for &(_, _, i) in &important_matched {
@@ -4251,11 +4530,10 @@ fn animation_directed_progress(
} else {
duration * iterations
};
let local_time = if timing.play_state == crate::AnimationPlayState::Paused {
0.0
} else {
sample_time.milliseconds
};
// The timeline owns pause/resume hold time. Keeping that state outside the
// sampler lets a running animation freeze at its current progress and
// later resume without manufacturing a new instance.
let local_time = sample_time.milliseconds;
if !local_time.is_finite() {
return None;
}
File diff suppressed because it is too large. Load diff
+199 -8
View File
@@ -97,15 +97,24 @@ mod image_capability_tests {
mod paint;
#[cfg(feature = "paint")]
pub use paint::{
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
image_intrinsic_dimensions, paint_dom, paint_dom_scrolled,
paint_dom_scrolled_at_animation_time, paint_prepared,
paint_prepared_region_with_scroll,
paint_prepared_with_scroll, prepare_dom, prepare_dom_at_animation_time,
prepare_dom_with_dynamic_fonts, prepare_dom_with_dynamic_fonts_at_animation_time,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time,
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state,
screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time,
prepare_dom_with_retained_attribute_styles, prepare_dom_with_retained_styles,
prepare_dom_with_retained_styles_at_animation_time,
prepare_dom_with_retained_styles_with_animation_state,
screenshot_prepared,
screenshot_prepared_region_with_scroll,
screenshot_prepared_region_with_scroll_and_backgrounds, screenshot_prepared_with_scroll,
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
ImageRequestProfile, PreparedRender, RenderResourceCache, RenderResourceLoader,
ResolvedScrollState, SelectedImage,
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
};
@@ -1140,6 +1149,12 @@ pub struct LayoutStyle {
/// before author `!important`, matching the animation cascade origin.
pub animation_name: Option<String>,
pub animation_timing: AnimationTiming,
/// True when the selected keyframes contain at least one property this
/// renderer can sample. Unsupported custom-property-only animations must
/// not keep layout or screencast damage active forever.
pub animation_has_render_effect: bool,
/// Local time used for this element's sampled animation instance.
pub animation_local_time_ms: f32,
/// `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
@@ -1488,15 +1503,49 @@ pub enum WordBreak {
BreakWord,
}
/// 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.
/// CSS animation sample time relative to the document timeline origin.
/// Live capture uses elapsed document time, while deterministic comparison
/// harnesses can request an exact instant such as T=0.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct AnimationSampleTime {
pub milliseconds: f32,
}
/// Which clock an animation sample represents.
///
/// Live rendering subtracts each element's animation-instance start epoch
/// from document time. Deterministic comparison instead assigns one local
/// time to every animation, matching Web Animations `currentTime` rather than
/// pretending all dynamically-created animations began at navigation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationSampleMode {
#[default]
DocumentTime,
LocalOverride,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct AnimationSample {
pub time: AnimationSampleTime,
pub mode: AnimationSampleMode,
}
impl AnimationSample {
pub fn document(milliseconds: f32) -> Self {
Self {
time: AnimationSampleTime { milliseconds },
mode: AnimationSampleMode::DocumentTime,
}
}
pub fn local_override(milliseconds: f32) -> Self {
Self {
time: AnimationSampleTime { milliseconds },
mode: AnimationSampleMode::LocalOverride,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationDirection {
#[default]
@@ -1549,6 +1598,148 @@ impl Default for AnimationTiming {
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AnimationInstanceKey {
pub name: String,
}
#[derive(Debug, Clone)]
struct AnimationInstance {
key: AnimationInstanceKey,
start_ms: f32,
hold_time_ms: Option<f32>,
was_paused: bool,
}
/// Page-owned CSS animation instance history retained across layout rebuilds.
/// Node ids are document-scoped, so navigation must replace this value.
#[derive(Debug, Default)]
pub struct AnimationTimelineState {
instances: std::collections::HashMap<obscura_dom::tree::NodeId, AnimationInstance>,
start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
subtree_start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
}
impl AnimationTimelineState {
pub fn note_start_candidate(
&mut self,
node: obscura_dom::tree::NodeId,
document_time_ms: f32,
) {
if document_time_ms.is_finite() && document_time_ms >= 0.0 {
self.start_candidates.insert(node, document_time_ms);
}
}
/// Defer expansion until the next style flush. This is needed for DOM
/// string APIs whose imported descendants do not exist until after the
/// mutation op has completed.
pub fn note_subtree_start_candidate(
&mut self,
root: obscura_dom::tree::NodeId,
document_time_ms: f32,
) {
if document_time_ms.is_finite() && document_time_ms >= 0.0 {
self.subtree_start_candidates
.insert(root, document_time_ms);
}
}
pub fn materialize_start_candidates(&mut self, tree: &obscura_dom::DomTree) {
for (root, start_ms) in std::mem::take(&mut self.subtree_start_candidates) {
if tree.get_node(root).is_none() {
continue;
}
self.start_candidates.insert(root, start_ms);
for node in tree.descendants(root) {
self.start_candidates.entry(node).or_insert(start_ms);
}
}
}
pub fn remove_subtree<'a>(
&mut self,
nodes: impl IntoIterator<Item = &'a obscura_dom::tree::NodeId>,
) {
for node in nodes {
self.instances.remove(node);
self.start_candidates.remove(node);
self.subtree_start_candidates.remove(node);
}
}
pub(crate) fn sample_for(
&mut self,
node: obscura_dom::tree::NodeId,
key: AnimationInstanceKey,
play_state: AnimationPlayState,
sample: AnimationSample,
) -> AnimationSampleTime {
if sample.mode == AnimationSampleMode::LocalOverride {
return sample.time;
}
let document_time_ms = sample.time.milliseconds;
let transition_time_ms = self.start_candidates.remove(&node);
let retained = self
.instances
.get_mut(&node)
.filter(|instance| instance.key == key);
let instance = match retained {
Some(instance) => instance,
None => {
let candidate = transition_time_ms.unwrap_or(0.0);
let paused = play_state == AnimationPlayState::Paused;
self.instances.insert(
node,
AnimationInstance {
key,
start_ms: if paused { document_time_ms } else { candidate },
hold_time_ms: paused.then_some(0.0),
was_paused: paused,
},
);
self.instances.get_mut(&node).expect("inserted animation instance")
}
};
let paused = play_state == AnimationPlayState::Paused;
if paused && !instance.was_paused {
let paused_at = transition_time_ms.unwrap_or(document_time_ms);
instance.hold_time_ms = Some((paused_at - instance.start_ms).max(0.0));
} else if !paused && instance.was_paused {
let held = instance.hold_time_ms.take().unwrap_or(0.0);
let resumed_at = transition_time_ms.unwrap_or(document_time_ms);
instance.start_ms = resumed_at - held;
}
instance.was_paused = paused;
AnimationSampleTime {
milliseconds: instance
.hold_time_ms
.unwrap_or_else(|| (document_time_ms - instance.start_ms).max(0.0)),
}
}
pub(crate) fn clear_animation(&mut self, node: obscura_dom::tree::NodeId, sample: AnimationSample) {
if sample.mode == AnimationSampleMode::DocumentTime {
self.instances.remove(&node);
}
}
pub fn retain_nodes(
&mut self,
mut keep: impl FnMut(obscura_dom::tree::NodeId) -> bool,
) {
self.instances.retain(|node, _| keep(*node));
self.start_candidates.retain(|node, _| keep(*node));
self.subtree_start_candidates.retain(|node, _| keep(*node));
}
pub fn clear_start_candidates(&mut self) {
self.start_candidates.clear();
self.subtree_start_candidates.clear();
}
}
/// 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.
+535 -20
View File
@@ -23,8 +23,8 @@ static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-obl
static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf");
use crate::dom::{
layout_dom_with_web_fonts_and_retained_styles,
layout_dom_with_web_fonts_and_stylesheet_cache, RetainedStyleMaps,
layout_dom_with_web_fonts_and_retained_styles_with_animation_state,
layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state, RetainedStyleMaps,
};
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
@@ -81,6 +81,49 @@ enum CachedResource {
Missing(std::time::Instant),
}
/// Fetch credentials/CORS identity for an HTML image request. No-CORS uses
/// the ordinary URL cache key so CSS images can share the same response;
/// CORS variants use private keys and can never contaminate one another.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ImageRequestProfile {
NoCorsInclude,
CorsSameOrigin,
CorsInclude,
}
fn image_request_profile(tree: &DomTree, id: obscura_dom::tree::NodeId) -> ImageRequestProfile {
match tree
.get_node(id)
.and_then(|node| node.get_attribute("crossorigin").map(str::to_owned))
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("use-credentials") => ImageRequestProfile::CorsInclude,
Some(_) => ImageRequestProfile::CorsSameOrigin,
None => ImageRequestProfile::NoCorsInclude,
}
}
fn image_resource_key(url: &str, profile: ImageRequestProfile) -> String {
let url = network_resource_url(url);
match profile {
ImageRequestProfile::NoCorsInclude => url,
ImageRequestProfile::CorsSameOrigin => format!("\0obscura-img-cors\0{url}"),
ImageRequestProfile::CorsInclude => format!("\0obscura-img-credentials\0{url}"),
}
}
fn network_resource_url(url: &str) -> String {
if url.starts_with('\0') || url.starts_with("data:") {
return url.to_string();
}
let Ok(mut parsed) = url::Url::parse(url) else {
return url.to_string();
};
parsed.set_fragment(None);
parsed.to_string()
}
#[derive(Clone, Debug, PartialEq)]
struct RememberedContentImageIntrinsic {
resolved_url: String,
@@ -162,13 +205,35 @@ impl RenderResourceCache {
}
pub fn has_live_outcome(&self, url: &str) -> bool {
match self.entries.get(url) {
match self.entries.get(&network_resource_url(url)) {
Some(CachedResource::Bytes(_)) => true,
Some(CachedResource::Missing(at)) => at.elapsed() < MISSING_RESOURCE_RETRY_AFTER,
None => false,
}
}
/// Whether this URL currently retains usable bytes (as opposed to a
/// short-lived negative-cache entry). Profile-specific HTML image loads
/// use this to avoid replacing paint bytes from another credential mode.
pub fn has_cached_bytes(&self, url: &str) -> bool {
matches!(
self.entries.get(&network_resource_url(url)),
Some(CachedResource::Bytes(_))
)
}
pub fn has_live_image_outcome(&self, url: &str, profile: ImageRequestProfile) -> bool {
self.has_live_outcome(&image_resource_key(url, profile))
}
pub fn seed_image(&mut self, url: String, profile: ImageRequestProfile, bytes: Vec<u8>) {
self.seed(image_resource_key(&url, profile), bytes);
}
pub fn seed_image_missing(&mut self, url: String, profile: ImageRequestProfile) {
self.seed_missing(image_resource_key(&url, profile));
}
/// Seed bytes fetched by the owning page's asynchronous browser transport.
///
/// Layout and paint are synchronous, so letting them open their own HTTP
@@ -176,6 +241,7 @@ impl RenderResourceCache {
/// layer uses this entry point to fetch a bounded resource batch through
/// its cookie/proxy/CORS-aware connection pool before entering layout.
pub fn seed(&mut self, url: String, bytes: Vec<u8>) {
let url = network_resource_url(&url);
self.remove(&url);
self.insert_bytes(url, Arc::from(bytes));
}
@@ -183,6 +249,7 @@ impl RenderResourceCache {
/// Retain a page-transport failure so capture does not immediately repeat
/// the same slow request through the renderer's compatibility loader.
pub fn seed_missing(&mut self, url: String) {
let url = network_resource_url(&url);
self.remove(&url);
self.insert_missing(url);
}
@@ -224,7 +291,30 @@ impl RenderResourceCache {
.map(|(width, height)| (resolved_url, width, height)),
);
}
match self.entries.get(&resolved_url) {
match self.entries.get(&network_resource_url(&resolved_url)) {
Some(CachedResource::Bytes(bytes)) => Some(
image_metadata_from_bytes(bytes)
.map(|(width, height)| (resolved_url, width, height)),
),
Some(CachedResource::Missing(at)) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
Some(None)
}
_ => None,
}
}
fn cached_profiled_image_metadata(
&self,
src: &str,
base_url: Option<&str>,
profile: ImageRequestProfile,
) -> Option<Option<(String, f32, f32)>> {
let resolved_url = resolve_resource_url(src, base_url)?;
if resolved_url.starts_with("data:") {
return self.cached_image_metadata(&resolved_url, None);
}
let key = image_resource_key(&resolved_url, profile);
match self.entries.get(&key) {
Some(CachedResource::Bytes(bytes)) => Some(
image_metadata_from_bytes(bytes)
.map(|(width, height)| (resolved_url, width, height)),
@@ -250,7 +340,8 @@ impl RenderResourceCache {
) -> Option<(String, f32, Option<(f32, f32)>)> {
let (src, density) = resolve_img_url(tree, id, viewport)?;
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
let dimensions = fetch_bytes(&resolved_url, None, self)
let profile = image_request_profile(tree, id);
let dimensions = fetch_profiled_image_bytes(&resolved_url, None, self, profile)
.and_then(|bytes| image_metadata_from_bytes(&bytes))
.map(|(width, height)| (width / density, height / density));
Some((resolved_url, density, dimensions))
@@ -268,7 +359,8 @@ impl RenderResourceCache {
) -> Option<(String, f32, bool, Option<(f32, f32)>)> {
let (src, density) = resolve_img_url(tree, id, viewport)?;
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
match self.cached_image_metadata(&resolved_url, None) {
let profile = image_request_profile(tree, id);
match self.cached_profiled_image_metadata(&resolved_url, None, profile) {
None => Some((resolved_url, density, false, None)),
Some(dimensions) => Some((
resolved_url,
@@ -280,7 +372,8 @@ impl RenderResourceCache {
}
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
if let Some(entry) = self.entries.get(url) {
let url = network_resource_url(url);
if let Some(entry) = self.entries.get(&url) {
match entry {
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
@@ -292,16 +385,53 @@ impl RenderResourceCache {
if !self.sync_loading_enabled {
return None;
}
self.remove(url);
self.remove(&url);
let loaded = self.loader.load(url).map(Arc::<[u8]>::from);
let loaded = self.loader.load(&url).map(Arc::<[u8]>::from);
match loaded {
Some(bytes) => {
self.insert_bytes(url.to_string(), Arc::clone(&bytes));
self.insert_bytes(url, Arc::clone(&bytes));
Some(bytes)
}
None => {
self.insert_missing(url.to_string());
self.insert_missing(url);
None
}
}
}
fn get_or_load_image(
&mut self,
url: &str,
profile: ImageRequestProfile,
) -> Option<Arc<[u8]>> {
let key = image_resource_key(url, profile);
if let Some(entry) = self.entries.get(&key) {
match entry {
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
return None;
}
CachedResource::Missing(_) => {}
}
}
if !self.sync_loading_enabled {
return None;
}
self.remove(&key);
// The private profile key is cache identity only and must never escape
// into a network loader.
let loaded = self
.loader
.load(&network_resource_url(url))
.map(Arc::<[u8]>::from);
match loaded {
Some(bytes) => {
self.insert_bytes(key, Arc::clone(&bytes));
Some(bytes)
}
None => {
self.insert_missing(key);
None
}
}
@@ -382,6 +512,7 @@ impl RenderResourceCache {
SelectedImage {
resolved_url: value.resolved_url,
density: 1.0,
profile: ImageRequestProfile::NoCorsInclude,
},
);
seeded.insert(nid);
@@ -426,11 +557,19 @@ impl RenderResourceCache {
}
}
/// Inspect already-fetched image bytes without inserting them into a resource
/// cache. HTMLImageElement keeps request-profile-specific lifecycle outcomes
/// even though the renderer's ordinary paint cache is URL-keyed.
pub fn image_intrinsic_dimensions(bytes: &[u8]) -> Option<(f32, f32)> {
image_metadata_from_bytes(bytes)
}
/// The exact responsive image candidate chosen during preparation.
#[derive(Clone, Debug, PartialEq)]
pub struct SelectedImage {
pub resolved_url: String,
pub density: f32,
pub profile: ImageRequestProfile,
}
/// CSSOM scrolling metrics for one element box. Non-scroll containers expose
@@ -616,6 +755,7 @@ impl ResolvedScrollState {
/// The DOM must not be mutated while this value is reused.
pub struct PreparedRender {
viewport: (f32, f32),
animation_sample: crate::AnimationSample,
root_font_size: f32,
base_url: Option<String>,
content_size: (f32, f32),
@@ -632,6 +772,51 @@ impl PreparedRender {
self.viewport
}
pub fn animation_sample_time(&self) -> crate::AnimationSampleTime {
self.animation_sample.time
}
pub fn animation_sample(&self) -> crate::AnimationSample {
self.animation_sample
}
/// Whether advancing the document timeline can still change a sampled CSS
/// animation. Finite animations stop producing compositor damage after
/// their active interval; paused and zero-duration animations never do.
pub fn has_active_css_animations(&self) -> bool {
self.layout.styles.values().any(|style| {
if style.animation_name.is_none()
|| !style.animation_has_render_effect
|| style.animation_timing.play_state == crate::AnimationPlayState::Paused
|| style.animation_timing.duration_ms <= 0.0
|| style.animation_timing.iteration_count <= 0.0
{
return false;
}
let end = style.animation_timing.delay_ms
+ style.animation_timing.duration_ms * style.animation_timing.iteration_count;
end.is_infinite() || style.animation_local_time_ms < end.max(0.0)
})
}
/// Advance a frame whose animation cascade is already time-invariant.
/// This preserves retained layout for static pages and completed finite
/// animations. Backward seeks are never eligible because they can re-enter
/// an earlier active interval or undo forwards fill.
pub fn advance_inactive_animation_sample_time(
&mut self,
sample: crate::AnimationSampleTime,
) -> bool {
if self.animation_sample.mode != crate::AnimationSampleMode::DocumentTime
|| sample.milliseconds < self.animation_sample.time.milliseconds
|| self.has_active_css_animations()
{
return false;
}
self.animation_sample.time = sample;
true
}
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}
@@ -1562,9 +1747,31 @@ pub fn paint_dom_scrolled(
viewport: (f32, f32),
base_url: Option<&str>,
scroll: (f32, f32),
) -> Option<Pixmap> {
paint_dom_scrolled_at_animation_time(
tree,
viewport,
base_url,
scroll,
crate::AnimationSampleTime::default(),
)
}
pub fn paint_dom_scrolled_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
scroll: (f32, f32),
animation_sample_time: crate::AnimationSampleTime,
) -> Option<Pixmap> {
let mut resources = RenderResourceCache::default();
let mut prepared = prepare_dom(tree, viewport, base_url, &mut resources)?;
let mut prepared = prepare_dom_at_animation_time(
tree,
viewport,
base_url,
&mut resources,
animation_sample_time,
)?;
paint_prepared(tree, &mut prepared, &mut resources, scroll)
}
@@ -1576,7 +1783,30 @@ pub fn prepare_dom(
base_url: Option<&str>,
resources: &mut RenderResourceCache,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts(tree, viewport, base_url, resources, &[])
prepare_dom_at_animation_time(
tree,
viewport,
base_url,
resources,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_at_animation_time(
tree,
viewport,
base_url,
resources,
&[],
animation_sample_time,
)
}
/// Prepare a DOM with the document's script-created `FontFace` registrations.
@@ -1589,15 +1819,34 @@ pub fn prepare_dom_with_dynamic_fonts(
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_with_dynamic_fonts_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
let mut stylesheet_cache = crate::css::StylesheetCache::default();
prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
&mut stylesheet_cache,
animation_sample_time,
)
}
@@ -1611,6 +1860,52 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
let mut animation_timeline = crate::AnimationTimelineState::default();
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
crate::AnimationSample {
time: animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
tree,
@@ -1620,6 +1915,8 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
dynamic_fonts,
stylesheet_cache,
None,
animation_sample,
animation_timeline,
)
}
@@ -1663,9 +1960,76 @@ pub fn prepare_dom_with_retained_styles(
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
mut previous: PreparedRender,
previous: PreparedRender,
mutations: &[crate::dom::RetainedStyleMutation],
) -> Option<PreparedRender> {
prepare_dom_with_retained_styles_at_animation_time(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
previous,
mutations,
crate::AnimationSampleTime::default(),
)
}
pub fn prepare_dom_with_retained_styles_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
previous: PreparedRender,
mutations: &[crate::dom::RetainedStyleMutation],
animation_sample_time: crate::AnimationSampleTime,
) -> Option<PreparedRender> {
let mut animation_timeline = crate::AnimationTimelineState::default();
prepare_dom_with_retained_styles_with_animation_state(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
previous,
mutations,
crate::AnimationSample {
time: animation_sample_time,
mode: crate::AnimationSampleMode::DocumentTime,
},
&mut animation_timeline,
)
}
pub fn prepare_dom_with_retained_styles_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
resources: &mut RenderResourceCache,
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
mut previous: PreparedRender,
mutations: &[crate::dom::RetainedStyleMutation],
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
if previous.animation_sample != animation_sample {
drop(previous);
return prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
base_url,
resources,
dynamic_fonts,
stylesheet_cache,
animation_sample,
animation_timeline,
);
}
let retained = RetainedStyleMaps {
styles: std::mem::take(&mut previous.layout.styles),
custom_properties: std::mem::take(&mut previous.layout.custom_properties),
@@ -1679,6 +2043,8 @@ pub fn prepare_dom_with_retained_styles(
dynamic_fonts,
stylesheet_cache,
Some((retained, mutations)),
animation_sample,
animation_timeline,
)
}
@@ -1690,6 +2056,8 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
dynamic_fonts: &[DynamicFontFace],
stylesheet_cache: &mut crate::css::StylesheetCache,
retained: Option<(RetainedStyleMaps, &[crate::dom::RetainedStyleMutation])>,
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0
{
@@ -1731,7 +2099,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
svg_font_database()
};
let mut laid = match retained {
Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles(
Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles_with_animation_state(
tree,
viewport,
&intrinsic,
@@ -1739,13 +2107,17 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
stylesheet_cache,
retained,
mutations,
animation_sample,
animation_timeline,
),
None => layout_dom_with_web_fonts_and_stylesheet_cache(
None => layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
&intrinsic,
&fonts,
stylesheet_cache,
animation_sample,
animation_timeline,
),
};
// `content:url(...)` is computed by the author cascade, whereas ordinary
@@ -1767,12 +2139,14 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
{
resources.content_image_layout_retries += 1;
}
laid = layout_dom_with_web_fonts_and_stylesheet_cache(
laid = layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
tree,
viewport,
&intrinsic,
&fonts,
stylesheet_cache,
animation_sample,
animation_timeline,
);
}
let content_size = laid.scrolling_content_size(tree, viewport);
@@ -1788,6 +2162,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
.unwrap_or(16.0);
Some(PreparedRender {
viewport,
animation_sample,
root_font_size,
base_url: base_url.map(str::to_string),
content_size,
@@ -2945,6 +3320,7 @@ fn paint_laid_dom_scrolled(
&mut pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask,
);
@@ -3048,6 +3424,7 @@ fn paint_laid_dom_scrolled(
style.object_fit,
&mut pixmap,
image_cache,
Some(source.profile),
None,
radius,
element_clip_mask,
@@ -3676,6 +4053,7 @@ fn paint_inline_fragment_decorations(
pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask.as_ref(),
);
@@ -4704,6 +5082,23 @@ pub fn screenshot_png_scrolled(
paint_dom_scrolled(tree, viewport, base_url, scroll).and_then(|pixmap| pixmap.encode_png().ok())
}
pub fn screenshot_png_scrolled_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>,
scroll: (f32, f32),
animation_sample_time: crate::AnimationSampleTime,
) -> Option<Vec<u8>> {
paint_dom_scrolled_at_animation_time(
tree,
viewport,
base_url,
scroll,
animation_sample_time,
)
.and_then(|pixmap| pixmap.encode_png().ok())
}
/// PNG convenience wrapper for a retained resource-aware layout.
pub fn screenshot_prepared(
tree: &DomTree,
@@ -5181,6 +5576,19 @@ fn fetch_bytes(
cache.get_or_load(&resolved)
}
fn fetch_profiled_image_bytes(
src: &str,
base_url: Option<&str>,
cache: &mut RenderResourceCache,
profile: ImageRequestProfile,
) -> Option<Arc<[u8]>> {
if src.starts_with("data:") {
return fetch_bytes(src, base_url, cache);
}
let resolved = resolve_resource_url(src, base_url)?;
cache.get_or_load_image(&resolved, profile)
}
fn resolve_resource_url(src: &str, base_url: Option<&str>) -> Option<String> {
if src.starts_with("data:") {
return Some(src.to_string());
@@ -6818,6 +7226,7 @@ fn paint_in_flow_generated_box(
pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask.as_ref(),
);
@@ -7051,6 +7460,7 @@ fn paint_positioned_pseudo(
pixmap,
image_cache,
None,
None,
background.clip_radii,
background_mask.as_ref(),
);
@@ -7143,14 +7553,16 @@ fn collect_image_intrinsics(
continue;
};
let resolved_url = resolve_resource_url(&url, base_url).unwrap_or(url);
let profile = image_request_profile(tree, nid);
selected.insert(
nid,
SelectedImage {
resolved_url: resolved_url.clone(),
density,
profile,
},
);
let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else {
let Some(bytes) = fetch_profiled_image_bytes(&resolved_url, None, cache, profile) else {
continue;
};
let dimensions = image_dimensions(&bytes)
@@ -7208,6 +7620,7 @@ fn collect_content_image_intrinsics(
SelectedImage {
resolved_url: resolved_url.clone(),
density: 1.0,
profile: ImageRequestProfile::NoCorsInclude,
},
);
// CSS content replaces the element's ordinary source. Remove the src
@@ -7528,6 +7941,7 @@ fn paint_image(
object_fit: crate::ObjectFit,
pixmap: &mut Pixmap,
cache: &mut RenderResourceCache,
profile: Option<ImageRequestProfile>,
transform: Option<crate::Affine2>,
clip_radius: crate::ResolvedBorderRadii,
extra_clip: Option<&tiny_skia::Mask>,
@@ -7541,7 +7955,11 @@ fn paint_image(
if !rect_intersects_paint_surface(visible_rect, pixmap, 1.0) {
return false;
}
let Some(bytes) = fetch_bytes(src, base_url, cache) else {
let bytes = match profile {
Some(profile) => fetch_profiled_image_bytes(src, base_url, cache, profile),
None => fetch_bytes(src, base_url, cache),
};
let Some(bytes) = bytes else {
return false;
};
let svg = is_svg(&bytes);
@@ -8746,6 +9164,56 @@ mod tests {
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn html_image_profiles_keep_intrinsic_geometry_and_paint_separate() {
let network_url = "https://assets.test/shared.svg";
let url = "https://assets.test/shared.svg#icon";
let tree = parse_html(&format!(
r#"<html style="margin:0"><body style="margin:0">
<img id="plain" src="{url}" style="display:block">
<img id="anonymous" crossorigin="anonymous" src="{url}" style="display:block">
<img id="credentialed" crossorigin="use-credentials" alt="" src="{url}"
style="display:block;width:20px;height:10px">
</body></html>"#
));
let mut resources = RenderResourceCache::with_loader(|_url: &str| {
panic!("seeded profile resources must not reach the loader")
});
resources.seed_image(
network_url.to_string(),
ImageRequestProfile::NoCorsInclude,
br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#f00"/></svg>"##.to_vec(),
);
resources.seed_image(
network_url.to_string(),
ImageRequestProfile::CorsSameOrigin,
br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="40" height="30" fill="#0f0"/></svg>"##.to_vec(),
);
resources.seed_image_missing(network_url.to_string(), ImageRequestProfile::CorsInclude);
let mut prepared =
prepare_dom(&tree, (80.0, 60.0), None, &mut resources).expect("profiled layout");
let node = |selector| tree.query_selector(selector).unwrap().unwrap();
let plain = prepared.layout().rects[&node("#plain")];
let anonymous = prepared.layout().rects[&node("#anonymous")];
assert_eq!(prepared.selected_image(node("#plain")).unwrap().resolved_url, url);
assert_eq!((plain.width, plain.height), (20.0, 10.0));
assert_eq!((anonymous.width, anonymous.height), (40.0, 30.0));
let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0))
.expect("profiled paint");
let red = pixmap.pixel(5, 5).unwrap();
assert!(red.red() > 240 && red.green() < 20, "{red:?}");
let green = pixmap.pixel(5, 20).unwrap();
assert!(green.green() > 240 && green.red() < 20, "{green:?}");
let failed = pixmap.pixel(5, 45).unwrap();
assert_eq!(
(failed.red(), failed.green(), failed.blue(), failed.alpha()),
(255, 255, 255, 255),
"failed credentialed image must not paint bytes from another profile"
);
}
#[test]
fn image_accept_advertises_exactly_decodable_mime_types() {
assert!(!IMAGE_ACCEPT.contains('*'));
@@ -9075,6 +9543,7 @@ mod tests {
Some(&SelectedImage {
resolved_url: "https://assets.test/hero.svg".to_string(),
density: 2.0,
profile: ImageRequestProfile::NoCorsInclude,
})
);
let hero_rect = prepared.layout().rects.get(&hero).expect("hero rect");
@@ -12572,6 +13041,7 @@ mod tests {
&mut pixmap,
&mut resources,
None,
None,
crate::ResolvedBorderRadii::default(),
None,
));
@@ -12612,4 +13082,49 @@ mod tests {
let sticky = pixmap.pixel(75, 15).expect("sticky pixel");
assert!(sticky.blue() > 240 && sticky.red() < 20);
}
#[test]
fn prepared_render_samples_explicit_animation_time_and_reports_live_damage() {
let tree = parse_html(
r#"<style>
@keyframes dismiss {
from { opacity:1; visibility:visible }
to { opacity:0; visibility:hidden }
}
#overlay { opacity:1; animation:dismiss 600ms linear forwards }
</style><div id="overlay" style="width:20px;height:20px;background:red"></div>"#,
);
let overlay = tree.query_selector("#overlay").unwrap().unwrap();
let mut resources = RenderResourceCache::with_loader(|_url: &str| None);
let mut stylesheets = crate::css::StylesheetCache::default();
let at_zero = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
&tree,
(40.0, 40.0),
None,
&mut resources,
&[],
&mut stylesheets,
crate::AnimationSampleTime { milliseconds: 0.0 },
)
.expect("T=0 render");
assert_eq!(at_zero.animation_sample_time().milliseconds, 0.0);
assert_eq!(at_zero.layout.styles[&overlay].opacity, Some(1.0));
assert_ne!(at_zero.layout.styles[&overlay].visibility_hidden, Some(true));
assert!(at_zero.has_active_css_animations());
let at_end = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
&tree,
(40.0, 40.0),
None,
&mut resources,
&[],
&mut stylesheets,
crate::AnimationSampleTime { milliseconds: 600.0 },
)
.expect("animation-end render");
assert_eq!(at_end.layout.styles[&overlay].opacity, Some(0.0));
assert_eq!(at_end.layout.styles[&overlay].visibility_hidden, Some(true));
assert!(at_end.layout.styles[&overlay].effectively_invisible);
assert!(!at_end.has_active_css_animations());
}
}