Speed up retained rendering and honor PDF backgrounds
This commit is contained in:
1 parent
222b50e480
commit
ede14b7740
9 files changed
+1385
-92
No files matched your search
@@ -48,17 +48,20 @@ pub struct InvalidationDependency {
|
||||
|
||||
/// Selector dependencies retained alongside the compiled stylesheet.
|
||||
///
|
||||
/// This follows Gecko's conservative invalidation-map shape: mutations look
|
||||
/// up the changed id/class/attribute/state and receive one or more traversal
|
||||
/// reaches. Phase 1 only builds and exposes this metadata; it deliberately does
|
||||
/// not alter mutation handling or skip any cascade work.
|
||||
/// This follows Gecko's conservative invalidation-map shape: live mutations
|
||||
/// look up the changed id/class/attribute/local-name/state and receive one or
|
||||
/// more traversal reaches. The renderer uses those reaches to retain clean
|
||||
/// computed styles, with a full-cascade fallback for unrepresentable paths.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InvalidationMap {
|
||||
ids: HashMap<String, Vec<InvalidationDependency>>,
|
||||
classes: HashMap<String, Vec<InvalidationDependency>>,
|
||||
attributes: HashMap<String, Vec<InvalidationDependency>>,
|
||||
local_names: HashMap<String, Vec<InvalidationDependency>>,
|
||||
states: HashMap<String, Vec<InvalidationDependency>>,
|
||||
conservative_rule_orders: Vec<usize>,
|
||||
relational_rule_orders: Vec<usize>,
|
||||
unkeyed_relational_rule_orders: Vec<usize>,
|
||||
}
|
||||
|
||||
impl InvalidationMap {
|
||||
@@ -77,6 +80,13 @@ impl InvalidationMap {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn local_name_dependencies(&self, local_name: &str) -> &[InvalidationDependency] {
|
||||
self.local_names
|
||||
.get(&local_name.to_ascii_lowercase())
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn state_dependencies(&self, state: &str) -> &[InvalidationDependency] {
|
||||
self.states
|
||||
.get(&state.to_ascii_lowercase())
|
||||
@@ -92,11 +102,20 @@ impl InvalidationMap {
|
||||
!self.conservative_rule_orders.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_relational_rule(&self, rule_order: usize) -> bool {
|
||||
self.relational_rule_orders.contains(&rule_order)
|
||||
}
|
||||
|
||||
pub fn has_unkeyed_relational_rules(&self) -> bool {
|
||||
!self.unkeyed_relational_rule_orders.is_empty()
|
||||
}
|
||||
|
||||
pub fn dependency_count(&self) -> usize {
|
||||
self.ids
|
||||
.values()
|
||||
.chain(self.classes.values())
|
||||
.chain(self.attributes.values())
|
||||
.chain(self.local_names.values())
|
||||
.chain(self.states.values())
|
||||
.map(Vec::len)
|
||||
.sum()
|
||||
@@ -139,6 +158,15 @@ impl InvalidationMap {
|
||||
);
|
||||
}
|
||||
|
||||
fn push_local_name(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
|
||||
Self::push(
|
||||
&mut self.local_names,
|
||||
key.to_ascii_lowercase(),
|
||||
rule_order,
|
||||
reaches,
|
||||
);
|
||||
}
|
||||
|
||||
fn push_state(&mut self, key: String, rule_order: usize, reaches: InvalidationReaches) {
|
||||
Self::push(
|
||||
&mut self.states,
|
||||
@@ -155,6 +183,15 @@ impl InvalidationMap {
|
||||
self.conservative_rule_orders.push(rule_order);
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_relational(&mut self, rule_order: usize, unkeyed: bool) {
|
||||
if !self.relational_rule_orders.contains(&rule_order) {
|
||||
self.relational_rule_orders.push(rule_order);
|
||||
}
|
||||
if unkeyed && !self.unkeyed_relational_rule_orders.contains(&rule_order) {
|
||||
self.unkeyed_relational_rule_orders.push(rule_order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_invalidation_reach(
|
||||
@@ -422,12 +459,123 @@ fn nth_of_selector(arguments: &str) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn compound_local_name(compound: &str) -> Option<String> {
|
||||
let chars = compound.trim().chars().collect::<Vec<_>>();
|
||||
let index;
|
||||
if chars.first() == Some(&'|') {
|
||||
index = 1;
|
||||
} else if chars.first() == Some(&'*') {
|
||||
if chars.get(1) != Some(&'|') {
|
||||
return None;
|
||||
}
|
||||
index = 2;
|
||||
} else {
|
||||
let (first, end) = consume_css_identifier(&chars, 0);
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if chars.get(end) != Some(&'|') {
|
||||
return Some(first.to_ascii_lowercase());
|
||||
}
|
||||
index = end + 1;
|
||||
}
|
||||
if chars.get(index) == Some(&'*') {
|
||||
return None;
|
||||
}
|
||||
let (local, _) = consume_css_identifier(&chars, index);
|
||||
(!local.is_empty()).then(|| local.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Whether the relative selector's subject compound has a positive key which
|
||||
/// an inserted/removed subtree can look up. Keys hidden only inside `:is()` or
|
||||
/// negation are deliberately not credited; treating those as unkeyed costs a
|
||||
/// broader invalidation but cannot miss an activation.
|
||||
fn relative_selector_subject_has_key(selector: &str) -> bool {
|
||||
let selector = selector.trim();
|
||||
let selector = selector
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|character| matches!(character, '>' | '+' | '~'))
|
||||
.map(|character| selector[character.len_utf8()..].trim_start())
|
||||
.unwrap_or(selector);
|
||||
let (compounds, malformed) = invalidation_compounds(selector);
|
||||
if malformed {
|
||||
return false;
|
||||
}
|
||||
let Some((subject, _)) = compounds.last() else {
|
||||
return false;
|
||||
};
|
||||
if compound_local_name(subject).is_some() {
|
||||
return true;
|
||||
}
|
||||
let chars = subject.chars().collect::<Vec<_>>();
|
||||
let mut paren_depth = 0usize;
|
||||
let mut bracket_depth = 0usize;
|
||||
let mut quote = None;
|
||||
let mut index = 0usize;
|
||||
while index < chars.len() {
|
||||
let ch = chars[index];
|
||||
if ch == '\\' {
|
||||
index = (index + 2).min(chars.len());
|
||||
continue;
|
||||
}
|
||||
if let Some(active_quote) = quote {
|
||||
if ch == active_quote {
|
||||
quote = None;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if ch == '\'' || ch == '"' {
|
||||
quote = Some(ch);
|
||||
} else if ch == ':' && paren_depth == 0 && bracket_depth == 0 {
|
||||
let (name, next) = consume_css_identifier(&chars, index + 1);
|
||||
if matches!(name.to_ascii_lowercase().as_str(), "is" | "where")
|
||||
&& chars.get(next) == Some(&'(')
|
||||
{
|
||||
let Some(close) = matching_delimiter(&chars, next, '(', ')') else {
|
||||
return false;
|
||||
};
|
||||
let arguments = chars[next + 1..close].iter().collect::<String>();
|
||||
let alternatives = split_selector_list(&arguments);
|
||||
if !alternatives.is_empty()
|
||||
&& alternatives.iter().all(|alternative| {
|
||||
relative_selector_subject_has_key(alternative.trim())
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
index = close + 1;
|
||||
continue;
|
||||
}
|
||||
} else if ch == '(' {
|
||||
paren_depth += 1;
|
||||
} else if ch == ')' {
|
||||
paren_depth = paren_depth.saturating_sub(1);
|
||||
} else if ch == '[' && paren_depth == 0 {
|
||||
if bracket_depth == 0 {
|
||||
return true;
|
||||
}
|
||||
bracket_depth += 1;
|
||||
} else if ch == ']' && paren_depth == 0 {
|
||||
bracket_depth = bracket_depth.saturating_sub(1);
|
||||
} else if paren_depth == 0 && bracket_depth == 0 && matches!(ch, '#' | '.') {
|
||||
return true;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn note_compound_dependencies(
|
||||
map: &mut InvalidationMap,
|
||||
compound: &str,
|
||||
reaches: InvalidationReaches,
|
||||
rule_order: usize,
|
||||
) {
|
||||
if let Some(local_name) = compound_local_name(compound) {
|
||||
map.push_local_name(local_name, rule_order, reaches);
|
||||
}
|
||||
let chars: Vec<char> = compound.chars().collect();
|
||||
let mut index = 0usize;
|
||||
while index < chars.len() {
|
||||
@@ -533,8 +681,18 @@ fn note_compound_dependencies(
|
||||
"has" => {
|
||||
// Relative selectors invalidate anchors upwards, which
|
||||
// Self/Descendants/Siblings cannot express soundly.
|
||||
map.push_state(
|
||||
name.clone(),
|
||||
rule_order,
|
||||
InvalidationReaches::CONSERVATIVE,
|
||||
);
|
||||
map.mark_conservative(rule_order);
|
||||
for alternative in split_selector_list(&arguments) {
|
||||
let alternatives = split_selector_list(&arguments);
|
||||
let unkeyed = alternatives.iter().any(|alternative| {
|
||||
!relative_selector_subject_has_key(alternative.trim())
|
||||
});
|
||||
map.mark_relational(rule_order, unkeyed);
|
||||
for alternative in alternatives {
|
||||
note_selector_dependencies(
|
||||
map,
|
||||
alternative.trim(),
|
||||
@@ -2299,13 +2457,25 @@ impl Stylesheet {
|
||||
.iter()
|
||||
.any(|&(_, _, i)| self.rules[i].important_flags.has_custom_properties);
|
||||
|
||||
// Most elements neither declare custom properties nor participate in
|
||||
// an `@property` registration. Reuse the inherited map directly in
|
||||
// that case instead of parsing every declaration stream, cloning the
|
||||
// map, and comparing the clone back to its source.
|
||||
let effective = if !has_own_custom_properties
|
||||
&& self.registered_custom_properties.is_empty()
|
||||
{
|
||||
// Registered properties are already represented in the parent's
|
||||
// computed map. Most descendants need no changes: inherited
|
||||
// registrations keep that value, while a non-inherited registration
|
||||
// whose parent already holds its initial value also stays identical.
|
||||
// Detect the uncommon transition before cloning the potentially large
|
||||
// custom-property map. This follows the browser rule-tree model where
|
||||
// unchanged computed values are shared down the tree.
|
||||
let registrations_change_parent = self.registered_custom_properties.iter().any(
|
||||
|(name, registration)| {
|
||||
if registration.inherits && parent_props.contains_key(name) {
|
||||
return false;
|
||||
}
|
||||
match ®istration.initial_value {
|
||||
Some(initial) => parent_props.get(name) != Some(initial),
|
||||
None => parent_props.contains_key(name),
|
||||
}
|
||||
},
|
||||
);
|
||||
let effective = if !has_own_custom_properties && !registrations_change_parent {
|
||||
None
|
||||
} else {
|
||||
let mut own: Vec<(String, String)> = Vec::new();
|
||||
@@ -2349,7 +2519,7 @@ impl Stylesheet {
|
||||
resolved_props.remove(name);
|
||||
}
|
||||
}
|
||||
let registration_changed_props = resolved_props != *parent_props;
|
||||
let registration_changed_props = registrations_change_parent;
|
||||
let has_own = !own.is_empty();
|
||||
let mut own_names = std::collections::HashSet::new();
|
||||
for (k, v) in own {
|
||||
@@ -5673,6 +5843,10 @@ mod tests {
|
||||
map.state_dependencies("focus-visible"),
|
||||
InvalidationReaches::SELF,
|
||||
));
|
||||
assert!(dependencies_reach(
|
||||
map.local_name_dependencies("BUTTON"),
|
||||
InvalidationReaches::SELF,
|
||||
));
|
||||
assert!(dependencies_reach(
|
||||
map.attribute_dependencies("disabled"),
|
||||
InvalidationReaches::SELF,
|
||||
@@ -5735,6 +5909,7 @@ mod tests {
|
||||
|
||||
assert!(map.requires_conservative_invalidation());
|
||||
assert!(map.conservative_rule_orders().len() >= 3);
|
||||
assert!(!map.has_unkeyed_relational_rules());
|
||||
assert!(dependencies_reach(
|
||||
map.class_dependencies("badge"),
|
||||
InvalidationReaches::CONSERVATIVE,
|
||||
@@ -5770,6 +5945,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidation_map_distinguishes_keyed_and_unkeyed_relational_subjects() {
|
||||
let keyed = test_invalidation_map(
|
||||
".card:has(> .badge), .row:has(.icon[data-live]), .copy:has(> span), .choice:has(:is(.yes,button)) { color:red }",
|
||||
);
|
||||
assert!(!keyed.has_unkeyed_relational_rules());
|
||||
assert!(keyed
|
||||
.class_dependencies("badge")
|
||||
.iter()
|
||||
.any(|dependency| keyed.is_relational_rule(dependency.rule_order)));
|
||||
assert!(keyed
|
||||
.local_name_dependencies("span")
|
||||
.iter()
|
||||
.any(|dependency| keyed.is_relational_rule(dependency.rule_order)));
|
||||
|
||||
for selector in [
|
||||
".card:has(> *){color:red}",
|
||||
".card:has(:is(.badge,*)){color:red}",
|
||||
".card:has(:empty){color:red}",
|
||||
] {
|
||||
assert!(
|
||||
test_invalidation_map(selector).has_unkeyed_relational_rules(),
|
||||
"{selector}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidation_map_includes_generated_content_host_dependencies() {
|
||||
let map = test_invalidation_map(
|
||||
@@ -7073,6 +7275,69 @@ mod tests {
|
||||
assert_eq!(initial_style.height, crate::Dimension::Percent(0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_custom_properties_reuse_an_unchanged_parent_map() {
|
||||
let tree = obscura_dom::parse_html(r#"<div id="target"></div>"#);
|
||||
let css = r#"
|
||||
@property --private {
|
||||
syntax:"<percentage>";
|
||||
inherits:false;
|
||||
initial-value:75%;
|
||||
}
|
||||
@property --shared {
|
||||
syntax:"<percentage>";
|
||||
inherits:true;
|
||||
initial-value:25%;
|
||||
}
|
||||
#target { width:var(--private); height:var(--shared) }
|
||||
"#;
|
||||
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
|
||||
let target = tree.get_element_by_id("target").unwrap();
|
||||
let parent_props = HashMap::from([
|
||||
("--private".to_string(), "75%".to_string()),
|
||||
("--shared".to_string(), "30%".to_string()),
|
||||
]);
|
||||
let mut matcher = tree.matcher();
|
||||
let mut style = LayoutStyle::default();
|
||||
let effective = sheet.apply(
|
||||
&tree,
|
||||
&mut matcher,
|
||||
target,
|
||||
Some("target"),
|
||||
&[],
|
||||
"div",
|
||||
&mut style,
|
||||
&parent_props,
|
||||
None,
|
||||
);
|
||||
assert!(effective.is_none());
|
||||
assert_eq!(style.width, crate::Dimension::Percent(0.75));
|
||||
assert_eq!(style.height, crate::Dimension::Percent(0.30));
|
||||
|
||||
let overridden_parent = HashMap::from([
|
||||
("--private".to_string(), "60%".to_string()),
|
||||
("--shared".to_string(), "30%".to_string()),
|
||||
]);
|
||||
let mut style = LayoutStyle::default();
|
||||
let effective = sheet
|
||||
.apply(
|
||||
&tree,
|
||||
&mut matcher,
|
||||
target,
|
||||
Some("target"),
|
||||
&[],
|
||||
"div",
|
||||
&mut style,
|
||||
&overridden_parent,
|
||||
None,
|
||||
)
|
||||
.expect("non-inherited registered property must reset on the child");
|
||||
assert_eq!(effective.get("--private").map(String::as_str), Some("75%"));
|
||||
assert_eq!(effective.get("--shared").map(String::as_str), Some("30%"));
|
||||
assert_eq!(style.width, crate::Dimension::Percent(0.75));
|
||||
assert_eq!(style.height, crate::Dimension::Percent(0.30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_custom_property_overrides_and_var_fallbacks_stay_distinct() {
|
||||
let tree = obscura_dom::parse_html(
|
||||
|
||||
@@ -355,9 +355,9 @@ pub struct DomLayout {
|
||||
}
|
||||
|
||||
/// One connected element attribute mutation eligible for conservative
|
||||
/// retained-style invalidation. Callers must use the ordinary full-layout
|
||||
/// path for tree/text mutations and for attributes with non-CSS rendering
|
||||
/// semantics (image sources, form state, inline style, and similar).
|
||||
/// retained-style invalidation. Attributes with independent HTML rendering
|
||||
/// semantics (image sources, form state, inline style, and similar) still use
|
||||
/// the ordinary full-layout path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AttributeStyleMutation {
|
||||
pub node: NodeId,
|
||||
@@ -366,6 +366,47 @@ pub struct AttributeStyleMutation {
|
||||
pub new_value: Option<String>,
|
||||
}
|
||||
|
||||
/// A connected tree change which can reuse computed styles from the previous
|
||||
/// render. Node ids are stable across detach/reparent operations; recording
|
||||
/// both parents lets the post-mutation traversal invalidate the old and new
|
||||
/// sibling scopes without retaining a second DOM snapshot.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TreeStyleMutation {
|
||||
Insert {
|
||||
node: NodeId,
|
||||
old_parent: Option<NodeId>,
|
||||
new_parent: NodeId,
|
||||
},
|
||||
Remove {
|
||||
node: NodeId,
|
||||
old_parent: NodeId,
|
||||
},
|
||||
Text {
|
||||
node: NodeId,
|
||||
parent: Option<NodeId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Mutation input for a retained-style rebuild. Attribute invalidation uses
|
||||
/// selector-key dependencies; tree invalidation uses parent/sibling scopes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RetainedStyleMutation {
|
||||
Attribute(AttributeStyleMutation),
|
||||
Tree(TreeStyleMutation),
|
||||
}
|
||||
|
||||
impl From<AttributeStyleMutation> for RetainedStyleMutation {
|
||||
fn from(mutation: AttributeStyleMutation) -> Self {
|
||||
Self::Attribute(mutation)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TreeStyleMutation> for RetainedStyleMutation {
|
||||
fn from(mutation: TreeStyleMutation) -> Self {
|
||||
Self::Tree(mutation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style maps moved out of a previous layout. Moving, rather than cloning,
|
||||
/// keeps incremental restyle from retaining a second heap-heavy LayoutStyle
|
||||
/// graph beside the final used styles.
|
||||
@@ -2396,6 +2437,111 @@ fn add_following_sibling_subtrees(
|
||||
}
|
||||
}
|
||||
|
||||
fn subtree_contains_style_element(tree: &DomTree, root: NodeId) -> bool {
|
||||
std::iter::once(root)
|
||||
.chain(tree.descendants(root))
|
||||
.any(|id| {
|
||||
tree.get_node(id).is_some_and(|node| {
|
||||
node.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == "style")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn node_is_style_text(tree: &DomTree, node: NodeId, parent: Option<NodeId>) -> bool {
|
||||
parent.is_some_and(|parent| {
|
||||
tree.get_node(parent).is_some_and(|node| {
|
||||
node.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == "style")
|
||||
})
|
||||
}) || tree.get_node(node).is_some_and(|node| {
|
||||
node.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == "style")
|
||||
})
|
||||
}
|
||||
|
||||
fn dependencies_include_relational_rule(
|
||||
map: &crate::css::InvalidationMap,
|
||||
dependencies: &[crate::css::InvalidationDependency],
|
||||
) -> bool {
|
||||
dependencies
|
||||
.iter()
|
||||
.any(|dependency| {
|
||||
map.is_relational_rule(dependency.rule_order)
|
||||
&& dependency
|
||||
.reaches
|
||||
.contains(crate::css::InvalidationReaches::CONSERVATIVE)
|
||||
})
|
||||
}
|
||||
|
||||
fn subtree_may_affect_relational_selector(
|
||||
tree: &DomTree,
|
||||
map: &crate::css::InvalidationMap,
|
||||
root: NodeId,
|
||||
) -> bool {
|
||||
if map.has_unkeyed_relational_rules() {
|
||||
return true;
|
||||
}
|
||||
std::iter::once(root)
|
||||
.chain(tree.descendants(root))
|
||||
.any(|id| {
|
||||
let Some(node) = tree.get_node(id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(_element) = node.as_element() else {
|
||||
return false;
|
||||
};
|
||||
if node.get_attribute("id").is_some_and(|id| {
|
||||
dependencies_include_relational_rule(map, map.id_dependencies(id))
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
if node.get_attribute("class").is_some_and(|classes| {
|
||||
classes.split_whitespace().any(|class| {
|
||||
dependencies_include_relational_rule(map, map.class_dependencies(class))
|
||||
})
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
if dependencies_include_relational_rule(
|
||||
map,
|
||||
map.local_name_dependencies(
|
||||
node.as_element()
|
||||
.map(|element| element.local.as_ref())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
node.attrs().is_some_and(|attributes| {
|
||||
attributes.iter().any(|attribute| {
|
||||
dependencies_include_relational_rule(
|
||||
map,
|
||||
map.attribute_dependencies(&attribute.qualified_name()),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A child-list change can alter every structural/sibling match under each
|
||||
/// affected parent. Re-cascade that complete parent subtree, the parent's
|
||||
/// following siblings (for `:empty +/~ ...`), and the ancestor context chain.
|
||||
/// This deliberately does not depend on whether the current sheet happens to
|
||||
/// contain nth/sibling selectors: the broad local scope remains correct when
|
||||
/// multiple queued mutations introduce selector keys before the next flush.
|
||||
fn add_tree_parent_scope(
|
||||
tree: &DomTree,
|
||||
parent: NodeId,
|
||||
dirty_following_siblings: bool,
|
||||
dirty: &mut HashSet<NodeId>,
|
||||
) {
|
||||
add_style_subtree(tree, parent, dirty);
|
||||
if dirty_following_siblings {
|
||||
add_following_sibling_subtrees(tree, parent, dirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert old/new selector keys into a conservative set of fresh cascade
|
||||
/// roots. Every selected root is expanded to its complete subtree so ordinary
|
||||
/// CSS inheritance, custom properties, generated content, and later selector
|
||||
@@ -2403,10 +2549,80 @@ fn add_following_sibling_subtrees(
|
||||
fn retained_style_plan(
|
||||
tree: &DomTree,
|
||||
sheet: &crate::css::Stylesheet,
|
||||
mutations: &[AttributeStyleMutation],
|
||||
mutations: &[RetainedStyleMutation],
|
||||
) -> RetainedStylePlan {
|
||||
let mut dirty = HashSet::new();
|
||||
for mutation in mutations {
|
||||
let RetainedStyleMutation::Attribute(mutation) = mutation else {
|
||||
let RetainedStyleMutation::Tree(mutation) = mutation else {
|
||||
unreachable!()
|
||||
};
|
||||
let map = sheet.invalidation_map();
|
||||
let dirty_parent_siblings = map.state_dependencies("empty").iter().any(|dependency| {
|
||||
dependency
|
||||
.reaches
|
||||
.contains(crate::css::InvalidationReaches::SIBLINGS)
|
||||
|| dependency
|
||||
.reaches
|
||||
.contains(crate::css::InvalidationReaches::CONSERVATIVE)
|
||||
});
|
||||
match *mutation {
|
||||
TreeStyleMutation::Insert {
|
||||
node,
|
||||
old_parent,
|
||||
new_parent,
|
||||
} => {
|
||||
// Inserting or moving a style subtree changes the ordered
|
||||
// author stylesheet, so parsing/index reuse is forbidden.
|
||||
if subtree_contains_style_element(tree, node)
|
||||
|| node_is_style_text(tree, node, old_parent)
|
||||
|| node_is_style_text(tree, node, Some(new_parent))
|
||||
{
|
||||
return RetainedStylePlan::Full;
|
||||
}
|
||||
if subtree_may_affect_relational_selector(tree, map, node)
|
||||
|| old_parent.is_some_and(|parent| {
|
||||
subtree_may_affect_relational_selector(tree, map, parent)
|
||||
})
|
||||
|| subtree_may_affect_relational_selector(tree, map, new_parent)
|
||||
{
|
||||
return RetainedStylePlan::Full;
|
||||
}
|
||||
add_style_subtree(tree, node, &mut dirty);
|
||||
if let Some(parent) = old_parent {
|
||||
add_tree_parent_scope(tree, parent, dirty_parent_siblings, &mut dirty);
|
||||
}
|
||||
add_tree_parent_scope(tree, new_parent, dirty_parent_siblings, &mut dirty);
|
||||
}
|
||||
TreeStyleMutation::Remove { node, old_parent } => {
|
||||
if subtree_contains_style_element(tree, node)
|
||||
|| node_is_style_text(tree, node, Some(old_parent))
|
||||
{
|
||||
return RetainedStylePlan::Full;
|
||||
}
|
||||
if subtree_may_affect_relational_selector(tree, map, node)
|
||||
|| subtree_may_affect_relational_selector(tree, map, old_parent)
|
||||
{
|
||||
return RetainedStylePlan::Full;
|
||||
}
|
||||
add_tree_parent_scope(tree, old_parent, dirty_parent_siblings, &mut dirty);
|
||||
}
|
||||
TreeStyleMutation::Text { node, parent } => {
|
||||
if node_is_style_text(tree, node, parent) {
|
||||
return RetainedStylePlan::Full;
|
||||
}
|
||||
if !map.state_dependencies("has").is_empty() {
|
||||
return RetainedStylePlan::Full;
|
||||
}
|
||||
if let Some(parent) = parent {
|
||||
add_tree_parent_scope(tree, parent, dirty_parent_siblings, &mut dirty);
|
||||
} else {
|
||||
dirty.insert(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let name = mutation.name.to_ascii_lowercase();
|
||||
// Phase one deliberately excludes attributes with independent HTML
|
||||
// rendering behavior. class/id and data/ARIA state cover framework
|
||||
@@ -2414,12 +2630,20 @@ fn retained_style_plan(
|
||||
// presentation hints, and inline declarations retain the full path.
|
||||
if name != "class"
|
||||
&& name != "id"
|
||||
&& name != "style"
|
||||
&& !name.starts_with("data-")
|
||||
&& !name.starts_with("aria-")
|
||||
{
|
||||
return RetainedStylePlan::Full;
|
||||
}
|
||||
|
||||
// Inline declarations always change this element's cascade and may
|
||||
// change inherited values/custom properties throughout its subtree,
|
||||
// even when no author selector explicitly mentions `[style]`.
|
||||
if name == "style" {
|
||||
add_style_subtree(tree, mutation.node, &mut dirty);
|
||||
}
|
||||
|
||||
let map = sheet.invalidation_map();
|
||||
let mut dependencies = Vec::new();
|
||||
dependencies.extend_from_slice(map.attribute_dependencies(&name));
|
||||
@@ -2495,7 +2719,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles(
|
||||
fonts: &[crate::inline::WebFont],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
retained: RetainedStyleMaps,
|
||||
mutations: &[AttributeStyleMutation],
|
||||
mutations: &[RetainedStyleMutation],
|
||||
) -> DomLayout {
|
||||
layout_dom_with_web_fonts_pass_limit(
|
||||
tree,
|
||||
@@ -2527,7 +2751,7 @@ fn layout_dom_with_web_fonts_pass_limit(
|
||||
pass_limit: Option<usize>,
|
||||
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
|
||||
retained: Option<RetainedStyleMaps>,
|
||||
mutations: &[AttributeStyleMutation],
|
||||
mutations: &[RetainedStyleMutation],
|
||||
) -> (DomLayout, ContainerLayoutTelemetry) {
|
||||
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
|
||||
|
||||
@@ -2558,7 +2782,7 @@ fn layout_dom_with_web_fonts_pass_limit(
|
||||
let t_parse = t0.elapsed();
|
||||
|
||||
let retained_requested = retained.as_ref().map_or(0, |retained| retained.styles.len());
|
||||
let retained = retained.and_then(|retained| {
|
||||
let retained = retained.and_then(|mut retained| {
|
||||
if !stylesheet_cache_hit
|
||||
|| retained
|
||||
.styles
|
||||
@@ -2567,6 +2791,13 @@ fn layout_dom_with_web_fonts_pass_limit(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let connected = std::iter::once(tree.document())
|
||||
.chain(tree.descendants(tree.document()))
|
||||
.collect::<HashSet<_>>();
|
||||
retained.styles.retain(|node, _| connected.contains(node));
|
||||
retained
|
||||
.custom_properties
|
||||
.retain(|node, _| connected.contains(node));
|
||||
match retained_style_plan(tree, &sheet, mutations) {
|
||||
RetainedStylePlan::Reuse(dirty) => Some((retained, dirty)),
|
||||
RetainedStylePlan::Full => None,
|
||||
@@ -12482,19 +12713,22 @@ mod tests {
|
||||
name: "data-theme".into(),
|
||||
old_value: Some("light".into()),
|
||||
new_value: Some("dark".into()),
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
AttributeStyleMutation {
|
||||
node: toggle,
|
||||
name: "data-open".into(),
|
||||
old_value: Some("false".into()),
|
||||
new_value: Some("true".into()),
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
AttributeStyleMutation {
|
||||
node: counter_change,
|
||||
name: "data-double".into(),
|
||||
old_value: Some("false".into()),
|
||||
new_value: Some("true".into()),
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
];
|
||||
let retained = RetainedStyleMaps {
|
||||
styles: std::mem::take(&mut initial.styles),
|
||||
@@ -12648,7 +12882,8 @@ mod tests {
|
||||
name: case.attribute.into(),
|
||||
old_value,
|
||||
new_value: Some(case.new_value.into()),
|
||||
}],
|
||||
}
|
||||
.into()],
|
||||
);
|
||||
let full = layout_dom(&tree, (320.0, 240.0));
|
||||
|
||||
@@ -12753,6 +12988,22 @@ mod tests {
|
||||
new_value: "on",
|
||||
expectation: Incremental,
|
||||
},
|
||||
RetainedDifferentialCase {
|
||||
name: "inline style and inherited custom property",
|
||||
css: ".subject .grand{width:var(--leaf-width,17px);color:inherit}",
|
||||
target: "subject",
|
||||
attribute: "style",
|
||||
new_value: "width:113px;height:29px;--leaf-width:37px;color:#345678",
|
||||
expectation: Incremental,
|
||||
},
|
||||
RetainedDifferentialCase {
|
||||
name: "inline style attribute selector reaches sibling",
|
||||
css: ".subject[style]+.panel{width:127px;height:31px}",
|
||||
target: "subject",
|
||||
attribute: "style",
|
||||
new_value: "height:23px",
|
||||
expectation: Incremental,
|
||||
},
|
||||
RetainedDifferentialCase {
|
||||
name: "counter propagation through retained siblings",
|
||||
css: ".scope{counter-reset:item}.subject,.panel{counter-increment:item}.subject[data-state=on]{counter-increment:item 3}.subject::before,.panel::before{content:counter(item)}",
|
||||
@@ -12832,6 +13083,320 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_retained_tree_case(
|
||||
name: &str,
|
||||
tree: &DomTree,
|
||||
cache: &mut crate::css::StylesheetCache,
|
||||
mut initial: DomLayout,
|
||||
mutation: TreeStyleMutation,
|
||||
expectation: RetainedDifferentialExpectation,
|
||||
) -> DomLayout {
|
||||
let retained = RetainedStyleMaps {
|
||||
styles: std::mem::take(&mut initial.styles),
|
||||
custom_properties: std::mem::take(&mut initial.custom_properties),
|
||||
};
|
||||
let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit(
|
||||
tree,
|
||||
(360.0, 260.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
None,
|
||||
Some(cache),
|
||||
Some(retained),
|
||||
&[mutation.into()],
|
||||
);
|
||||
let full = layout_dom(tree, (360.0, 260.0));
|
||||
match expectation {
|
||||
RetainedDifferentialExpectation::Incremental => {
|
||||
assert_eq!(telemetry.retained_fallback, 0, "{name}");
|
||||
assert!(telemetry.retained_fresh > 0, "{name}");
|
||||
assert!(telemetry.retained_reused > 0, "{name}");
|
||||
}
|
||||
RetainedDifferentialExpectation::ReuseAll => {
|
||||
assert_eq!(telemetry.retained_fallback, 0, "{name}");
|
||||
assert_eq!(telemetry.retained_fresh, 0, "{name}");
|
||||
assert!(telemetry.retained_reused > 0, "{name}");
|
||||
}
|
||||
RetainedDifferentialExpectation::ConservativeFallback => {
|
||||
assert_eq!(telemetry.retained_fallback, 1, "{name}");
|
||||
assert_eq!(telemetry.retained_reused, 0, "{name}");
|
||||
}
|
||||
}
|
||||
assert_computed_styles_match(name, &incremental, &full);
|
||||
assert_eq!(incremental.rects, full.rects, "{name}: border boxes");
|
||||
assert_eq!(
|
||||
incremental.inline_fragments, full.inline_fragments,
|
||||
"{name}: inline fragments"
|
||||
);
|
||||
assert_eq!(incremental.text_runs, full.text_runs, "{name}: text runs");
|
||||
assert_eq!(
|
||||
incremental.custom_properties, full.custom_properties,
|
||||
"{name}: custom properties"
|
||||
);
|
||||
incremental
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_tree_styles_match_forced_full_mutation_matrix() {
|
||||
use RetainedDifferentialExpectation::{ConservativeFallback, Incremental};
|
||||
|
||||
// A fresh insertion must cascade the inserted subtree and every
|
||||
// structurally affected sibling while retaining an unrelated branch.
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
.list{counter-reset:item}.item{counter-increment:item;height:9px}
|
||||
.item::before{content:counter(item)}
|
||||
.item:nth-child(2){width:83px}.item:first-child{color:#123456}
|
||||
.list:empty + .after{height:77px}.clean{width:91px;height:13px}
|
||||
</style><main><section id="list" class="list">
|
||||
<i id="first" class="item"></i><i id="insert" class="item"></i><i id="last" class="item"></i>
|
||||
</section><div class="after"></div><aside class="clean"></aside></main>"#,
|
||||
);
|
||||
let list = tree.get_element_by_id("list").unwrap();
|
||||
let insert = tree.get_element_by_id("insert").unwrap();
|
||||
let last = tree.get_element_by_id("last").unwrap();
|
||||
tree.remove_child(insert);
|
||||
let mut cache = crate::css::StylesheetCache::default();
|
||||
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
&tree,
|
||||
(360.0, 260.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
&mut cache,
|
||||
);
|
||||
tree.insert_before(last, insert);
|
||||
finish_retained_tree_case(
|
||||
"fresh insert with nth, counters, and pseudos",
|
||||
&tree,
|
||||
&mut cache,
|
||||
initial,
|
||||
TreeStyleMutation::Insert {
|
||||
node: insert,
|
||||
old_parent: None,
|
||||
new_parent: list,
|
||||
},
|
||||
Incremental,
|
||||
);
|
||||
|
||||
// Removing a subtree must purge its now-detached style/custom-property
|
||||
// entries instead of growing the retained maps forever.
|
||||
let tree = parse_html(
|
||||
r#"<style>.row:nth-child(even){width:72px}.row::before{content:'x'}.clean{height:15px}</style>
|
||||
<main id="rows"><div class="row"></div><div id="removed" class="row"><b id="removed-child"></b></div><div class="row"></div></main><aside class="clean"></aside>"#,
|
||||
);
|
||||
let rows = tree.get_element_by_id("rows").unwrap();
|
||||
let removed = tree.get_element_by_id("removed").unwrap();
|
||||
let removed_child = tree.get_element_by_id("removed-child").unwrap();
|
||||
let mut cache = crate::css::StylesheetCache::default();
|
||||
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
&tree,
|
||||
(360.0, 260.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
&mut cache,
|
||||
);
|
||||
tree.remove_child(removed);
|
||||
let incremental = finish_retained_tree_case(
|
||||
"remove and purge detached subtree",
|
||||
&tree,
|
||||
&mut cache,
|
||||
initial,
|
||||
TreeStyleMutation::Remove {
|
||||
node: removed,
|
||||
old_parent: rows,
|
||||
},
|
||||
Incremental,
|
||||
);
|
||||
assert!(!incremental.styles.contains_key(&removed));
|
||||
assert!(!incremental.styles.contains_key(&removed_child));
|
||||
assert!(!incremental.custom_properties.contains_key(&removed));
|
||||
assert!(!incremental.custom_properties.contains_key(&removed_child));
|
||||
|
||||
// Character-data changes keep selector matching local but must rebuild
|
||||
// text shaping and parent geometry.
|
||||
let tree = parse_html(
|
||||
r#"<style>#label{display:block;width:120px}.clean{height:17px}</style><div id="label">a</div><aside class="clean"></aside>"#,
|
||||
);
|
||||
let label = tree.get_element_by_id("label").unwrap();
|
||||
let text = tree.children(label)[0];
|
||||
let mut cache = crate::css::StylesheetCache::default();
|
||||
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
&tree,
|
||||
(360.0, 260.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
&mut cache,
|
||||
);
|
||||
tree.with_node_mut(text, |node| {
|
||||
if let obscura_dom::tree::NodeData::Text { contents } = &mut node.data {
|
||||
*contents = "a substantially longer replacement".into();
|
||||
}
|
||||
});
|
||||
finish_retained_tree_case(
|
||||
"text replacement",
|
||||
&tree,
|
||||
&mut cache,
|
||||
initial,
|
||||
TreeStyleMutation::Text {
|
||||
node: text,
|
||||
parent: Some(label),
|
||||
},
|
||||
Incremental,
|
||||
);
|
||||
|
||||
// Keyed :has() rules do not poison unrelated mutations, but a matching
|
||||
// inserted key still takes the correctness-first full path.
|
||||
for (name, inserted_class, expectation) in [
|
||||
("unrelated keyed has insertion", "other", Incremental),
|
||||
("matching keyed has insertion", "signal", ConservativeFallback),
|
||||
] {
|
||||
let html = format!(
|
||||
r#"<style>.host:has(.signal) .dependent{{width:101px}}.clean{{height:19px}}</style>
|
||||
<section id="host" class="host"><div id="target"><i id="insert" class="{inserted_class}"></i></div><span class="dependent"></span></section><aside class="clean"></aside>"#
|
||||
);
|
||||
let tree = parse_html(&html);
|
||||
let target = tree.get_element_by_id("target").unwrap();
|
||||
let insert = tree.get_element_by_id("insert").unwrap();
|
||||
tree.remove_child(insert);
|
||||
let mut cache = crate::css::StylesheetCache::default();
|
||||
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
&tree,
|
||||
(360.0, 260.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
&mut cache,
|
||||
);
|
||||
tree.append_child(target, insert);
|
||||
finish_retained_tree_case(
|
||||
name,
|
||||
&tree,
|
||||
&mut cache,
|
||||
initial,
|
||||
TreeStyleMutation::Insert {
|
||||
node: insert,
|
||||
old_parent: None,
|
||||
new_parent: target,
|
||||
},
|
||||
expectation,
|
||||
);
|
||||
}
|
||||
|
||||
for (name, inserted_tag, expectation) in [
|
||||
("unrelated keyed tag has insertion", "i", Incremental),
|
||||
("matching keyed tag has insertion", "span", ConservativeFallback),
|
||||
] {
|
||||
let html = format!(
|
||||
r#"<style>.host:has(> span) .dependent{{width:103px}}.clean{{height:21px}}</style>
|
||||
<section id="host" class="host"><{inserted_tag} id="insert"></{inserted_tag}><div class="dependent"></div></section><aside class="clean"></aside>"#
|
||||
);
|
||||
let tree = parse_html(&html);
|
||||
let target = tree.get_element_by_id("host").unwrap();
|
||||
let insert = tree.get_element_by_id("insert").unwrap();
|
||||
tree.remove_child(insert);
|
||||
let mut cache = crate::css::StylesheetCache::default();
|
||||
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
&tree,
|
||||
(360.0, 260.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
&mut cache,
|
||||
);
|
||||
tree.append_child(target, insert);
|
||||
finish_retained_tree_case(
|
||||
name,
|
||||
&tree,
|
||||
&mut cache,
|
||||
initial,
|
||||
TreeStyleMutation::Insert {
|
||||
node: insert,
|
||||
old_parent: None,
|
||||
new_parent: target,
|
||||
},
|
||||
expectation,
|
||||
);
|
||||
}
|
||||
|
||||
// Style text changes alter the cache key and must never reuse styles.
|
||||
let tree = parse_html(
|
||||
r#"<style id="sheet">.subject{width:20px}</style><div class="subject"></div>"#,
|
||||
);
|
||||
let style = tree.get_element_by_id("sheet").unwrap();
|
||||
let text = tree.children(style)[0];
|
||||
let mut cache = crate::css::StylesheetCache::default();
|
||||
let initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
&tree,
|
||||
(360.0, 260.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
&mut cache,
|
||||
);
|
||||
tree.with_node_mut(text, |node| {
|
||||
if let obscura_dom::tree::NodeData::Text { contents } = &mut node.data {
|
||||
*contents = ".subject{width:140px}".into();
|
||||
}
|
||||
});
|
||||
finish_retained_tree_case(
|
||||
"style text fallback",
|
||||
&tree,
|
||||
&mut cache,
|
||||
initial,
|
||||
TreeStyleMutation::Text {
|
||||
node: text,
|
||||
parent: Some(style),
|
||||
},
|
||||
ConservativeFallback,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_tree_invalidation_keeps_large_unrelated_branch_clean() {
|
||||
let mut clean = String::new();
|
||||
for index in 0..2_000 {
|
||||
clean.push_str(&format!("<span class=clean data-index={index}></span>"));
|
||||
}
|
||||
let tree = parse_html(&format!(
|
||||
r#"<style>.item:nth-child(2){{width:88px}}.clean{{height:1px}}</style>
|
||||
<main><section id=list><i class=item></i><i id=insert class=item></i></section><aside>{clean}</aside></main>"#
|
||||
));
|
||||
let list = tree.get_element_by_id("list").unwrap();
|
||||
let insert = tree.get_element_by_id("insert").unwrap();
|
||||
tree.remove_child(insert);
|
||||
let mut cache = crate::css::StylesheetCache::default();
|
||||
let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
&tree,
|
||||
(800.0, 600.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
&mut cache,
|
||||
);
|
||||
tree.append_child(list, insert);
|
||||
let retained = RetainedStyleMaps {
|
||||
styles: std::mem::take(&mut initial.styles),
|
||||
custom_properties: std::mem::take(&mut initial.custom_properties),
|
||||
};
|
||||
let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit(
|
||||
&tree,
|
||||
(800.0, 600.0),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
None,
|
||||
Some(&mut cache),
|
||||
Some(retained),
|
||||
&[TreeStyleMutation::Insert {
|
||||
node: insert,
|
||||
old_parent: None,
|
||||
new_parent: list,
|
||||
}
|
||||
.into()],
|
||||
);
|
||||
let full = layout_dom(&tree, (800.0, 600.0));
|
||||
assert_computed_styles_match("large unrelated branch", &incremental, &full);
|
||||
assert_eq!(incremental.rects, full.rects);
|
||||
assert_eq!(telemetry.retained_fallback, 0);
|
||||
assert!(telemetry.retained_reused >= 2_000, "{telemetry:?}");
|
||||
assert!(telemetry.retained_fresh < 16, "{telemetry:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_convergence_classifies_self_consistent_stability() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -2932,6 +2932,27 @@ impl TextEngine {
|
||||
clip_override: Option<Rect>,
|
||||
clip_mask: Option<&tiny_skia::Mask>,
|
||||
raster_scale: f32,
|
||||
) {
|
||||
self.paint_item_with_clip_mask_scaled_for_print(
|
||||
idx,
|
||||
pixmap,
|
||||
offset,
|
||||
clip_override,
|
||||
clip_mask,
|
||||
raster_scale,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn paint_item_with_clip_mask_scaled_for_print(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
pixmap: &mut tiny_skia::Pixmap,
|
||||
offset: (f32, f32),
|
||||
clip_override: Option<Rect>,
|
||||
clip_mask: Option<&tiny_skia::Mask>,
|
||||
raster_scale: f32,
|
||||
print_economy: bool,
|
||||
) {
|
||||
let TextEngine {
|
||||
font_system,
|
||||
@@ -3030,10 +3051,13 @@ impl TextEngine {
|
||||
});
|
||||
}
|
||||
}
|
||||
let col = g
|
||||
let mut col = g
|
||||
.color_opt
|
||||
.map(|c| [c.r(), c.g(), c.b(), c.a()])
|
||||
.unwrap_or([0, 0, 0, 255]);
|
||||
if print_economy {
|
||||
col = crate::paint::print_economy_color(col);
|
||||
}
|
||||
if underlined {
|
||||
match &mut seg {
|
||||
Some((_, x1, fs, c, prior_relative))
|
||||
@@ -3125,6 +3149,9 @@ impl TextEngine {
|
||||
let physical = glyph.physical((line_offset + relative.0, relative.1), raster_scale);
|
||||
let glyph_color = glyph.color_opt.unwrap_or(default);
|
||||
let fill_index = metadata_fill(glyph.metadata);
|
||||
if print_economy && fill_index.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut draw_pixel = |x, y, color: Color| {
|
||||
// cosmic-text's mask rasterizer replaces the authored
|
||||
// alpha with glyph coverage (see its `blend base alpha?`
|
||||
@@ -3136,7 +3163,7 @@ impl TextEngine {
|
||||
}
|
||||
let gx = physical.x + x;
|
||||
let gy = (run.line_y * raster_scale) as i32 + physical.y + y;
|
||||
let (r, g, b) = fill_index
|
||||
let (mut r, mut g, mut b) = fill_index
|
||||
.and_then(|index| {
|
||||
let fill = clip_fills.get(index)?;
|
||||
let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?;
|
||||
@@ -3150,6 +3177,10 @@ impl TextEngine {
|
||||
Some((sampled[0], sampled[1], sampled[2]))
|
||||
})
|
||||
.unwrap_or_else(|| (color.r(), color.g(), color.b()));
|
||||
if print_economy {
|
||||
let adjusted = crate::paint::print_economy_color([r, g, b, 255]);
|
||||
(r, g, b) = (adjusted[0], adjusted[1], adjusted[2]);
|
||||
}
|
||||
let px = ox as i32 + gx;
|
||||
let py = oy as i32 + gy;
|
||||
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use border::{
|
||||
pub mod dom;
|
||||
pub use dom::{
|
||||
layout_dom, layout_dom_with_images, layout_dom_with_resources, AttributeStyleMutation,
|
||||
DomLayout, StickyLayout,
|
||||
DomLayout, RetainedStyleMutation, StickyLayout, TreeStyleMutation,
|
||||
};
|
||||
|
||||
/// Whether an image MIME type names a format supported by the renderer build.
|
||||
@@ -100,8 +100,10 @@ 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,
|
||||
prepare_dom_with_retained_attribute_styles, screenshot_prepared,
|
||||
screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll,
|
||||
prepare_dom_with_retained_attribute_styles, prepare_dom_with_retained_styles,
|
||||
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,
|
||||
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||
|
||||
@@ -1634,8 +1634,37 @@ pub fn prepare_dom_with_retained_attribute_styles(
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
mut previous: PreparedRender,
|
||||
previous: PreparedRender,
|
||||
mutations: &[crate::dom::AttributeStyleMutation],
|
||||
) -> Option<PreparedRender> {
|
||||
let mutations = mutations
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(crate::dom::RetainedStyleMutation::Attribute)
|
||||
.collect::<Vec<_>>();
|
||||
prepare_dom_with_retained_styles(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
previous,
|
||||
&mutations,
|
||||
)
|
||||
}
|
||||
|
||||
/// Rebuild geometry while retaining clean styles across both selector-key
|
||||
/// attribute changes and conservatively scoped tree/text changes.
|
||||
pub fn prepare_dom_with_retained_styles(
|
||||
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],
|
||||
) -> Option<PreparedRender> {
|
||||
let retained = RetainedStyleMaps {
|
||||
styles: std::mem::take(&mut previous.layout.styles),
|
||||
@@ -1660,7 +1689,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
retained: Option<(RetainedStyleMaps, &[crate::dom::AttributeStyleMutation])>,
|
||||
retained: Option<(RetainedStyleMaps, &[crate::dom::RetainedStyleMutation])>,
|
||||
) -> Option<PreparedRender> {
|
||||
if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0
|
||||
{
|
||||
@@ -1812,6 +1841,7 @@ pub fn paint_prepared(
|
||||
None,
|
||||
(0.0, 0.0),
|
||||
1.0,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1856,6 +1886,7 @@ pub fn paint_prepared_with_scroll(
|
||||
None,
|
||||
(0.0, 0.0),
|
||||
1.0,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1869,6 +1900,27 @@ pub fn paint_prepared_region_with_scroll(
|
||||
resources: &mut RenderResourceCache,
|
||||
scroll: &ResolvedScrollState,
|
||||
region: CaptureRegion,
|
||||
) -> Result<Pixmap, CaptureError> {
|
||||
paint_prepared_region_with_scroll_policy(tree, prepared, resources, scroll, region, false)
|
||||
}
|
||||
|
||||
fn paint_prepared_region_with_scroll_with_print_economy(
|
||||
tree: &DomTree,
|
||||
prepared: &mut PreparedRender,
|
||||
resources: &mut RenderResourceCache,
|
||||
scroll: &ResolvedScrollState,
|
||||
region: CaptureRegion,
|
||||
) -> Result<Pixmap, CaptureError> {
|
||||
paint_prepared_region_with_scroll_policy(tree, prepared, resources, scroll, region, true)
|
||||
}
|
||||
|
||||
fn paint_prepared_region_with_scroll_policy(
|
||||
tree: &DomTree,
|
||||
prepared: &mut PreparedRender,
|
||||
resources: &mut RenderResourceCache,
|
||||
scroll: &ResolvedScrollState,
|
||||
region: CaptureRegion,
|
||||
print_economy: bool,
|
||||
) -> Result<Pixmap, CaptureError> {
|
||||
let (native_width, native_height, output_width, output_height) =
|
||||
checked_capture_dimensions(region)?;
|
||||
@@ -1917,6 +1969,7 @@ pub fn paint_prepared_region_with_scroll(
|
||||
Some((region.width, region.height)),
|
||||
surface_offset,
|
||||
raster_scale,
|
||||
print_economy,
|
||||
)
|
||||
.ok_or(CaptureError::PaintFailed)?;
|
||||
|
||||
@@ -2140,6 +2193,7 @@ fn paint_laid_dom_scrolled(
|
||||
surface_extent: Option<(f32, f32)>,
|
||||
surface_offset: (f32, f32),
|
||||
raster_scale: f32,
|
||||
print_economy: bool,
|
||||
) -> Option<Pixmap> {
|
||||
let scroll_state = match resolved_scroll {
|
||||
Some(resolved) => ScrollPaintState::from_resolved(
|
||||
@@ -2348,6 +2402,7 @@ fn paint_laid_dom_scrolled(
|
||||
surface_extent,
|
||||
surface_offset,
|
||||
raster_scale,
|
||||
print_economy,
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
@@ -2372,13 +2427,14 @@ fn paint_laid_dom_scrolled(
|
||||
)
|
||||
});
|
||||
for &item in items {
|
||||
laid.text_engine.paint_item_with_clip_mask_scaled(
|
||||
laid.text_engine.paint_item_with_clip_mask_scaled_for_print(
|
||||
item,
|
||||
&mut pixmap,
|
||||
offset,
|
||||
clip,
|
||||
clip_mask.as_ref(),
|
||||
raster_scale,
|
||||
print_economy,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -2445,6 +2501,7 @@ fn paint_laid_dom_scrolled(
|
||||
surface_extent,
|
||||
surface_offset,
|
||||
raster_scale,
|
||||
print_economy,
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
@@ -2519,6 +2576,7 @@ fn paint_laid_dom_scrolled(
|
||||
surface_offset.1 + layer_delta.1,
|
||||
),
|
||||
raster_scale,
|
||||
print_economy,
|
||||
)?;
|
||||
let transform =
|
||||
display_transform.then(crate::Affine2::translate(-layer_delta.0, -layer_delta.1));
|
||||
@@ -2587,6 +2645,7 @@ fn paint_laid_dom_scrolled(
|
||||
surface_extent,
|
||||
surface_offset,
|
||||
raster_scale,
|
||||
print_economy,
|
||||
)?;
|
||||
let group_paint = tiny_skia::PixmapPaint {
|
||||
opacity: own_opacity,
|
||||
@@ -3277,26 +3336,28 @@ fn paint_laid_dom_scrolled(
|
||||
)
|
||||
});
|
||||
if let Some(idx) = whole {
|
||||
laid.text_engine.paint_item_with_clip_mask_scaled(
|
||||
laid.text_engine.paint_item_with_clip_mask_scaled_for_print(
|
||||
idx,
|
||||
&mut pixmap,
|
||||
off,
|
||||
clip,
|
||||
clip_mask.as_ref(),
|
||||
raster_scale,
|
||||
print_economy,
|
||||
);
|
||||
}
|
||||
// Anonymous inline-run leaves of a mixed block (see
|
||||
// `build_mixed_block`), pinned to their own boxes at finalize.
|
||||
if let Some(items) = run_items {
|
||||
for idx in items {
|
||||
laid.text_engine.paint_item_with_clip_mask_scaled(
|
||||
laid.text_engine.paint_item_with_clip_mask_scaled_for_print(
|
||||
idx,
|
||||
&mut pixmap,
|
||||
off,
|
||||
clip,
|
||||
clip_mask.as_ref(),
|
||||
raster_scale,
|
||||
print_economy,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4549,6 +4610,144 @@ pub fn screenshot_prepared_region_with_scroll(
|
||||
.map_err(|_| CaptureError::EncodeFailed)
|
||||
}
|
||||
|
||||
/// Capture a retained region using the PDF print-background policy. Ordinary
|
||||
/// screenshots pass `true`. When false, authored fills are replaced with the
|
||||
/// browser print-economy white fill and light text is darkened for legibility;
|
||||
/// borders, shadows, replaced content, and masks remain paintable.
|
||||
pub fn screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||
tree: &DomTree,
|
||||
prepared: &mut PreparedRender,
|
||||
resources: &mut RenderResourceCache,
|
||||
scroll: &ResolvedScrollState,
|
||||
region: CaptureRegion,
|
||||
paint_backgrounds: bool,
|
||||
) -> Result<Vec<u8>, CaptureError> {
|
||||
if paint_backgrounds {
|
||||
return screenshot_prepared_region_with_scroll(tree, prepared, resources, scroll, region);
|
||||
}
|
||||
|
||||
let snapshots = prepared
|
||||
.layout
|
||||
.styles
|
||||
.iter_mut()
|
||||
.map(|(&node, style)| (node, PrintEconomyStyleSnapshot::apply(style)))
|
||||
.collect::<Vec<_>>();
|
||||
let result = paint_prepared_region_with_scroll_with_print_economy(
|
||||
tree, prepared, resources, scroll, region,
|
||||
)
|
||||
.and_then(|pixmap| pixmap.encode_png().map_err(|_| CaptureError::EncodeFailed));
|
||||
for (node, snapshot) in snapshots {
|
||||
if let Some(style) = prepared.layout.styles.get_mut(&node) {
|
||||
snapshot.restore(style);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
struct PrintEconomyStyleSnapshot {
|
||||
background_color: Option<[u8; 4]>,
|
||||
background_gradient: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
|
||||
background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||
background_conic_gradient: Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||
background_gradient_layers: Vec<crate::BackgroundGradientLayer>,
|
||||
background_image: Option<String>,
|
||||
color: Option<[u8; 4]>,
|
||||
before_pseudo: Option<Box<PrintEconomyStyleSnapshot>>,
|
||||
after_pseudo: Option<Box<PrintEconomyStyleSnapshot>>,
|
||||
}
|
||||
|
||||
impl PrintEconomyStyleSnapshot {
|
||||
fn apply(style: &mut crate::LayoutStyle) -> Self {
|
||||
let background_color = style.background_color.take();
|
||||
let background_gradient = style.background_gradient.take();
|
||||
let background_radial_gradient = style.background_radial_gradient.take();
|
||||
let background_conic_gradient = style.background_conic_gradient.take();
|
||||
let background_gradient_layers = std::mem::take(&mut style.background_gradient_layers);
|
||||
let background_image = style.background_image.take();
|
||||
let had_background = background_color.is_some_and(|color| color[3] != 0)
|
||||
|| background_gradient.is_some()
|
||||
|| background_radial_gradient.is_some()
|
||||
|| background_conic_gradient.is_some()
|
||||
|| !background_gradient_layers.is_empty()
|
||||
|| background_image.is_some();
|
||||
style.background_color = had_background.then_some([255, 255, 255, 255]);
|
||||
let color = style.color;
|
||||
style.color = color.map(print_economy_color);
|
||||
let before_pseudo = style
|
||||
.before_pseudo
|
||||
.as_deref_mut()
|
||||
.map(Self::apply)
|
||||
.map(Box::new);
|
||||
let after_pseudo = style
|
||||
.after_pseudo
|
||||
.as_deref_mut()
|
||||
.map(Self::apply)
|
||||
.map(Box::new);
|
||||
Self {
|
||||
background_color,
|
||||
background_gradient,
|
||||
background_radial_gradient,
|
||||
background_conic_gradient,
|
||||
background_gradient_layers,
|
||||
background_image,
|
||||
color,
|
||||
before_pseudo,
|
||||
after_pseudo,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(self, style: &mut crate::LayoutStyle) {
|
||||
style.background_color = self.background_color;
|
||||
style.background_gradient = self.background_gradient;
|
||||
style.background_radial_gradient = self.background_radial_gradient;
|
||||
style.background_conic_gradient = self.background_conic_gradient;
|
||||
style.background_gradient_layers = self.background_gradient_layers;
|
||||
style.background_image = self.background_image;
|
||||
style.color = self.color;
|
||||
if let (Some(snapshot), Some(pseudo)) =
|
||||
(self.before_pseudo, style.before_pseudo.as_deref_mut())
|
||||
{
|
||||
snapshot.restore(pseudo);
|
||||
}
|
||||
if let (Some(snapshot), Some(pseudo)) =
|
||||
(self.after_pseudo, style.after_pseudo.as_deref_mut())
|
||||
{
|
||||
snapshot.restore(pseudo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blink's print-economy foreground correction: colors too close to white
|
||||
/// move one third down the HSV value axis while preserving hue and alpha.
|
||||
pub(crate) fn print_economy_color(color: [u8; 4]) -> [u8; 4] {
|
||||
const MIN_DIFFERENCE_SQUARED: i32 = 65_025;
|
||||
let difference = |target: u8| -> i32 {
|
||||
color[..3]
|
||||
.iter()
|
||||
.map(|component| {
|
||||
let delta = i32::from(*component) - i32::from(target);
|
||||
delta * delta
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
if difference(255) > MIN_DIFFERENCE_SQUARED {
|
||||
return color;
|
||||
}
|
||||
let max = color[0].max(color[1]).max(color[2]) as f32 / 255.0;
|
||||
if max <= f32::EPSILON {
|
||||
return color;
|
||||
}
|
||||
let scale = (max - 0.33).max(0.0) / max;
|
||||
let adjusted =
|
||||
|component: u8| ((f32::from(component) / 255.0 * scale * 256.0) as u16).min(255) as u8;
|
||||
[
|
||||
adjusted(color[0]),
|
||||
adjusted(color[1]),
|
||||
adjusted(color[2]),
|
||||
color[3],
|
||||
]
|
||||
}
|
||||
|
||||
/// A representative visible color for `background-clip: text` text whose own
|
||||
/// color is transparent, used on the word-split paint path (the cosmic-text IFC
|
||||
/// path samples the gradient per glyph in `inline`). Returns the gradient's mid
|
||||
@@ -11692,6 +11891,69 @@ mod tests {
|
||||
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_background_policy_matches_chromium_economy_and_restores_screen_paint() {
|
||||
assert_eq!(
|
||||
print_economy_color([255, 255, 255, 255]),
|
||||
[171, 171, 171, 255]
|
||||
);
|
||||
assert_eq!(
|
||||
print_economy_color([105, 105, 105, 255]),
|
||||
[105, 105, 105, 255]
|
||||
);
|
||||
assert_eq!(print_economy_color([110, 110, 110, 255]), [25, 25, 25, 255]);
|
||||
assert_eq!(print_economy_color([255, 0, 0, 255]), [255, 0, 0, 255]);
|
||||
let tree = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0;background:#123456">
|
||||
<div style="box-sizing:border-box;width:100px;height:100px;
|
||||
background:#ff0000;border:10px solid #0000ff;
|
||||
color:#ffffff;font:40px sans-serif">X</div>
|
||||
<div style="position:relative;width:120px;height:120px">
|
||||
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Crect width='120' height='120' fill='orange'/%3E%3C/svg%3E"
|
||||
style="position:absolute;inset:0;width:120px;height:120px">
|
||||
<div style="position:absolute;left:20px;top:20px;width:80px;height:80px;
|
||||
background-image:linear-gradient(#00ff00,#00ff00)"></div>
|
||||
</div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut resources = RenderResourceCache::default();
|
||||
let mut prepared = prepare_dom(&tree, (120.0, 220.0), None, &mut resources)
|
||||
.expect("prepared print fixture");
|
||||
let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new());
|
||||
let region = CaptureRegion::new(0.0, 0.0, 120.0, 220.0, 1.0);
|
||||
|
||||
let screen_before = screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||
&tree, &mut prepared, &mut resources, &scroll, region, true,
|
||||
)
|
||||
.expect("screen capture before print");
|
||||
let economy = screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||
&tree, &mut prepared, &mut resources, &scroll, region, false,
|
||||
)
|
||||
.expect("print economy capture");
|
||||
let screen_after = screenshot_prepared_region_with_scroll_and_backgrounds(
|
||||
&tree, &mut prepared, &mut resources, &scroll, region, true,
|
||||
)
|
||||
.expect("screen capture after print");
|
||||
assert_eq!(screen_before, screen_after, "print paint must not mutate retained style");
|
||||
|
||||
let screen = image::load_from_memory_with_format(&screen_before, image::ImageFormat::Png)
|
||||
.unwrap()
|
||||
.into_rgb8();
|
||||
let economy = image::load_from_memory_with_format(&economy, image::ImageFormat::Png)
|
||||
.unwrap()
|
||||
.into_rgb8();
|
||||
assert_eq!(screen.get_pixel(80, 80).0, [255, 0, 0]);
|
||||
assert_eq!(economy.get_pixel(80, 80).0, [255, 255, 255]);
|
||||
assert_eq!(economy.get_pixel(5, 50).0, [0, 0, 255]);
|
||||
assert_eq!(screen.get_pixel(50, 150).0, [0, 255, 0]);
|
||||
assert_eq!(economy.get_pixel(5, 105).0, [255, 165, 0]);
|
||||
assert_eq!(economy.get_pixel(50, 150).0, [255, 255, 255]);
|
||||
assert!((10..90).any(|y| (10..90).any(|x| {
|
||||
let [r, g, b] = economy.get_pixel(x, y).0;
|
||||
(140..=200).contains(&r) && r.abs_diff(g) <= 2 && r.abs_diff(b) <= 2
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_region_capture_rejects_invalid_and_oversized_surfaces_before_paint() {
|
||||
let tree = parse_html("<div style='width:10px;height:10px;background:red'></div>");
|
||||
|
||||
Reference in new issue
Block a user