Make rendering asynchronous and timeline-aware
This commit is contained in:
1 parent
68f20cf525
commit
66f04d0f43
18 files changed
+3952
-290
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user