Speed up retained rendering and honor PDF backgrounds

This commit is contained in:
SGavrl committed 2026-08-03 22:08:44 +00:00
1 parent 222b50e480
commit ede14b7740
9 files changed
+1379 -86

No files matched your search

+6 -7
View File
@@ -21,6 +21,7 @@ const MAX_PDF_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RasterPdfOptions {
pub landscape: bool,
pub print_background: bool,
pub paper_width_in: f32,
pub paper_height_in: f32,
pub margin_top_in: f32,
@@ -33,6 +34,7 @@ impl Default for RasterPdfOptions {
fn default() -> Self {
Self {
landscape: false,
print_background: false,
paper_width_in: 8.5,
paper_height_in: 11.0,
// CDP's defaults are one centimetre.
@@ -232,13 +234,10 @@ impl Page {
let y = page_index as f32 * plan.css_page_height;
let slice_height = (content_height - y).min(plan.css_page_height);
let png = js
.screenshot_prepared_region(CaptureRegion::new(
0.0,
y,
content_width,
slice_height,
1.0,
))
.screenshot_prepared_region_with_backgrounds(
CaptureRegion::new(0.0, y, content_width, slice_height, 1.0),
options.print_background,
)
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
.map_err(|error| RasterPdfError::ImageDecode(error.to_string()))?;
+11 -11
View File
@@ -66,10 +66,6 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
));
}
}
// Current Puppeteer and Playwright always send their false default. The
// raster paginator cannot suppress backgrounds without a second paint
// mode, so accept the protocol shape and disclose the limitation in the
// extension capability fields returned with the result.
let requested_print_background = boolean(params, "printBackground", false)?;
// Puppeteer currently sends true by default. Raster PDFs have no semantic
// structure to tag, but rejecting the request makes the standard client
@@ -114,6 +110,7 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
Ok(ParsedPdfOptions {
raster: obscura_browser::RasterPdfOptions {
landscape: boolean(params, "landscape", defaults.landscape)?,
print_background: requested_print_background,
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
@@ -144,20 +141,18 @@ pub async fn print_to_pdf(
.map_err(|error| error.to_string())?;
let mut response = json!({
"obscuraPrintMode": "screen-raster",
"obscuraPrintBackground": true,
"obscuraPrintBackground": options.requested_print_background,
"obscuraRequestedPrintBackground": options.requested_print_background,
"obscuraTaggedPDF": false,
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
"obscuraCapabilities": {
"cssPagedMedia": false,
"honorsPrintBackground": false,
"honorsPrintBackground": true,
"printColorAdjustExact": false,
"taggedPdf": false,
},
});
let mut ignored_options = Vec::new();
if !options.requested_print_background {
ignored_options.push("printBackground");
}
if options.requested_tagged_pdf {
ignored_options.push("generateTaggedPDF");
}
@@ -215,6 +210,7 @@ mod tests {
.expect("current Puppeteer defaults must be accepted");
assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream);
assert!(!standard.requested_print_background);
assert!(!standard.raster.print_background);
assert!(standard.requested_tagged_pdf);
for params in [
@@ -315,13 +311,17 @@ mod tests {
.await
.expect("Puppeteer/Playwright-shaped stream request");
assert_eq!(streamed["data"], "");
assert_eq!(streamed["obscuraPrintBackground"], true);
assert_eq!(streamed["obscuraPrintBackground"], false);
assert_eq!(streamed["obscuraRequestedPrintBackground"], false);
assert_eq!(
streamed["obscuraCapabilities"]["honorsPrintBackground"],
true
);
assert_eq!(streamed["obscuraTaggedPDF"], false);
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
assert_eq!(
streamed["obscuraIgnoredOptions"],
json!(["printBackground", "generateTaggedPDF"])
json!(["generateTaggedPDF"])
);
let handle = streamed["stream"].as_str().expect("protocol stream handle");
let mut streamed_bytes = Vec::new();
+81 -25
View File
@@ -129,11 +129,11 @@ pub struct ObscuraState {
/// DOM/style/viewport changes clear this value but retain resource bytes.
#[cfg(feature = "render")]
pub prepared_render: Option<obscura_render::PreparedRender>,
/// Connected attribute mutations awaiting dependency-indexed retained
/// style refresh. Tree/text and non-attribute invalidations still discard
/// the prepared render immediately.
/// Connected mutations awaiting dependency-indexed retained style refresh.
/// Tree changes carry stable node/parent ids so a later geometry read can
/// coalesce framework DOM churn into one conservative local cascade.
#[cfg(feature = "render")]
pub pending_attribute_style_mutations: Vec<obscura_render::AttributeStyleMutation>,
pub pending_style_mutations: Vec<obscura_render::RetainedStyleMutation>,
/// Page-lifetime raw image/font bytes. A new document resets this cache;
/// relayout of the same document reuses it without refetching.
#[cfg(feature = "render")]
@@ -202,7 +202,7 @@ impl ObscuraState {
#[cfg(feature = "render")]
prepared_render: None,
#[cfg(feature = "render")]
pending_attribute_style_mutations: Vec::new(),
pending_style_mutations: Vec::new(),
#[cfg(feature = "render")]
render_resources: obscura_render::RenderResourceCache::default(),
#[cfg(feature = "render")]
@@ -473,17 +473,18 @@ fn render_mutation_impact(
}
#[cfg(feature = "render")]
fn retained_attribute_style_mutation(
fn retained_style_mutation(
dom: &DomTree,
cmd: &str,
arg1: &str,
arg2: &str,
) -> Option<obscura_render::AttributeStyleMutation> {
) -> Option<obscura_render::RetainedStyleMutation> {
let node = NodeId::new(arg1.parse::<u32>().ok()?);
let can_retain = |name: &str| {
let name = name.to_ascii_lowercase();
name == "class"
|| name == "id"
|| name == "style"
|| name.starts_with("data-")
|| name.starts_with("aria-")
};
@@ -493,23 +494,78 @@ fn retained_attribute_style_mutation(
if !can_retain(name) {
return None;
}
let keeps_selector_value = !name.eq_ignore_ascii_case("style");
Some(obscura_render::AttributeStyleMutation {
node,
name: name.to_string(),
old_value: dom
.with_node(node, |node| node.get_attribute(name).map(str::to_owned))
.flatten(),
new_value: Some(value.to_string()),
old_value: keeps_selector_value
.then(|| {
dom.with_node(node, |node| {
node.get_attribute(name).map(str::to_owned)
})
.flatten()
})
.flatten(),
new_value: keeps_selector_value.then(|| value.to_string()),
}
"remove_attribute" => can_retain(arg2).then(|| obscura_render::AttributeStyleMutation {
.into())
}
"remove_attribute" => can_retain(arg2).then(|| {
let keeps_selector_value = !arg2.eq_ignore_ascii_case("style");
obscura_render::AttributeStyleMutation {
node,
name: arg2.to_string(),
old_value: dom
.with_node(node, |node| node.get_attribute(arg2).map(str::to_owned))
old_value: keeps_selector_value
.then(|| {
dom.with_node(node, |node| {
node.get_attribute(arg2).map(str::to_owned)
})
.flatten()
})
.flatten(),
new_value: None,
}
.into()
}),
"append_child" => {
let child = NodeId::new(arg2.parse::<u32>().ok()?);
dom.get_node(node)?;
let old_parent = dom.get_node(child)?.parent;
Some(
obscura_render::TreeStyleMutation::Insert {
node: child,
old_parent,
new_parent: node,
}
.into(),
)
}
"remove_child" => {
let old_parent = dom.get_node(node)?.parent?;
Some(
obscura_render::TreeStyleMutation::Remove { node, old_parent }.into(),
)
}
"insert_before" => {
let reference = NodeId::new(arg2.parse::<u32>().ok()?);
let new_parent = dom.get_node(reference)?.parent?;
let old_parent = dom.get_node(node)?.parent;
Some(
obscura_render::TreeStyleMutation::Insert {
node,
old_parent,
new_parent,
}
.into(),
)
}
"set_text_content" => Some(
obscura_render::TreeStyleMutation::Text {
node,
parent: dom.get_node(node)?.parent,
}
.into(),
),
_ => None,
}
}
@@ -655,10 +711,10 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
.map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2))
.unwrap_or_default();
#[cfg(feature = "render")]
let retained_attribute_mutation = state
let retained_style_mutation = state
.dom
.as_ref()
.and_then(|dom| retained_attribute_style_mutation(dom, &cmd, &arg1, &arg2));
.and_then(|dom| retained_style_mutation(dom, &cmd, &arg1, &arg2));
let invalidate = impact.connected && impact.actual_change;
if invalidate {
state.activity_generation = state.activity_generation.wrapping_add(1);
@@ -674,18 +730,18 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
let had_prepared_render = state.prepared_render.is_some();
#[cfg(feature = "render")]
if invalidate {
if let Some(mutation) = retained_attribute_mutation {
if let Some(mutation) = retained_style_mutation {
if state.prepared_render.is_some()
&& state.pending_attribute_style_mutations.len() < 256
&& state.pending_style_mutations.len() < 256
{
state.pending_attribute_style_mutations.push(mutation);
state.pending_style_mutations.push(mutation);
} else {
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.pending_style_mutations.clear();
}
} else {
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.pending_style_mutations.clear();
}
state.resolved_scroll = None;
}
@@ -3024,7 +3080,7 @@ fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool
if state.dynamic_fonts != fonts {
state.dynamic_fonts = fonts;
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.pending_style_mutations.clear();
state.resolved_scroll = None;
}
true
@@ -3108,13 +3164,13 @@ pub(crate) fn ensure_prepared_render(
let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
});
if stale || !state.pending_attribute_style_mutations.is_empty() {
if stale || !state.pending_style_mutations.is_empty() {
let previous = (!stale).then(|| state.prepared_render.take()).flatten();
let mutations = std::mem::take(&mut state.pending_attribute_style_mutations);
let mutations = std::mem::take(&mut state.pending_style_mutations);
let prepared = {
let dom = state.dom.as_ref()?;
match previous {
Some(previous) => obscura_render::prepare_dom_with_retained_attribute_styles(
Some(previous) => obscura_render::prepare_dom_with_retained_styles(
dom,
viewport,
base_url.as_deref(),
+122 -9
View File
@@ -264,7 +264,7 @@ impl ObscuraJsRuntime {
#[cfg(feature = "render")]
{
gs.prepared_render = None;
gs.pending_attribute_style_mutations.clear();
gs.pending_style_mutations.clear();
gs.render_resources = obscura_render::RenderResourceCache::default();
gs.stylesheet_cache = obscura_render::StylesheetCache::default();
gs.dynamic_fonts.clear();
@@ -285,7 +285,7 @@ impl ObscuraJsRuntime {
// present. Keep already-fetched absolute bytes, but rebuild
// candidate selection/layout against the new base.
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.pending_style_mutations.clear();
state.resolved_scroll = None;
}
}
@@ -396,7 +396,7 @@ impl ObscuraJsRuntime {
if state.viewport != viewport {
state.viewport = viewport;
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.pending_style_mutations.clear();
state.resolved_scroll = None;
}
}
@@ -465,6 +465,17 @@ impl ObscuraJsRuntime {
pub fn screenshot_prepared_region(
&self,
region: obscura_render::CaptureRegion,
) -> Result<Vec<u8>, obscura_render::CaptureError> {
self.screenshot_prepared_region_with_backgrounds(region, true)
}
/// Capture a document-space rectangle with the PDF print-background
/// policy without mutating the page DOM or retained geometry.
#[cfg(feature = "render")]
pub fn screenshot_prepared_region_with_backgrounds(
&self,
region: obscura_render::CaptureRegion,
paint_backgrounds: bool,
) -> Result<Vec<u8>, obscura_render::CaptureError> {
let mut state = self.state.borrow_mut();
with_sync_render_loading_disabled(&mut state, |state| {
@@ -479,7 +490,7 @@ impl ObscuraJsRuntime {
let (_, scroll) = resolved_scroll
.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?;
obscura_render::screenshot_prepared_region_with_scroll(
obscura_render::screenshot_prepared_region_with_scroll_and_backgrounds(
dom.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?,
prepared_render
@@ -488,6 +499,7 @@ impl ObscuraJsRuntime {
render_resources,
scroll,
region,
paint_backgrounds,
)
})
}
@@ -557,7 +569,7 @@ impl ObscuraJsRuntime {
Some(bytes) => {
state.render_resources.seed(url, bytes);
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.pending_style_mutations.clear();
state.resolved_scroll = None;
}
None => state.render_resources.seed_missing(url),
@@ -1576,7 +1588,7 @@ impl ObscuraJsRuntime {
#[cfg(feature = "render")]
{
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.pending_style_mutations.clear();
state.render_resources = obscura_render::RenderResourceCache::default();
state.stylesheet_cache = obscura_render::StylesheetCache::default();
state.dynamic_fonts.clear();
@@ -6141,10 +6153,19 @@ mod tests {
"document.getElementById('box').setAttribute('style', 'height:60px;width:40px')",
)
.unwrap();
{
let state = rt.state.borrow();
assert!(
rt.state.borrow().prepared_render.is_none(),
"a changed attribute on a connected element must invalidate layout"
state.prepared_render.is_some(),
"a retained inline-style change must keep the prior style maps until flush"
);
assert!(matches!(
state.pending_style_mutations.as_slice(),
[obscura_render::RetainedStyleMutation::Attribute(
obscura_render::AttributeStyleMutation { name, .. }
)] if name == "style"
));
}
assert_eq!(
rt.evaluate("document.getElementById('box').getBoundingClientRect().height")
.unwrap()
@@ -6189,7 +6210,7 @@ mod tests {
{
let state = rt.state.borrow();
assert!(state.prepared_render.is_some());
assert_eq!(state.pending_attribute_style_mutations.len(), 1);
assert_eq!(state.pending_style_mutations.len(), 1);
}
assert_eq!(
rt.evaluate("document.getElementById('box').getBoundingClientRect().width")
@@ -6235,6 +6256,98 @@ mod tests {
assert_eq!(state.stylesheet_cache.hit_count(), 1);
}
#[cfg(feature = "render")]
#[test]
fn connected_tree_mutations_queue_retained_styles_until_geometry_flush() {
let dom = parse_html(
r#"<html style="margin:0"><head><style>
.item{display:block;width:40px;height:12px}
.item:nth-child(2){width:80px}
</style></head><body style="margin:0">
<main id="list"><div id="first" class="item"></div></main>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
assert_eq!(
rt.evaluate("document.getElementById('first').getBoundingClientRect().width")
.unwrap()
.as_f64(),
Some(40.0)
);
rt.evaluate(
"const added=document.createElement('div');added.id='added';added.className='item';document.getElementById('list').appendChild(added)",
)
.unwrap();
{
let state = rt.state.borrow();
assert!(state.prepared_render.is_some());
assert!(matches!(
state.pending_style_mutations.as_slice(),
[obscura_render::RetainedStyleMutation::Tree(
obscura_render::TreeStyleMutation::Insert { .. }
)]
));
}
assert_eq!(
rt.evaluate("document.getElementById('added').getBoundingClientRect().width")
.unwrap()
.as_f64(),
Some(80.0)
);
assert!(rt.state.borrow().pending_style_mutations.is_empty());
rt.evaluate("document.getElementById('added').style.width='65px'")
.unwrap();
{
let state = rt.state.borrow();
assert!(state.prepared_render.is_some());
assert!(matches!(
state.pending_style_mutations.as_slice(),
[obscura_render::RetainedStyleMutation::Attribute(
obscura_render::AttributeStyleMutation { name, .. }
)] if name == "style"
));
}
assert_eq!(
rt.evaluate("document.getElementById('added').getBoundingClientRect().width")
.unwrap()
.as_f64(),
Some(65.0)
);
assert_eq!(
rt.evaluate("(function(){const added=document.getElementById('added');added.style.width='';return added.getBoundingClientRect().width})()")
.unwrap()
.as_f64(),
Some(80.0)
);
rt.evaluate(
"document.getElementById('list').removeChild(document.getElementById('first'))",
)
.unwrap();
{
let state = rt.state.borrow();
assert!(state.prepared_render.is_some());
assert!(matches!(
state.pending_style_mutations.as_slice(),
[obscura_render::RetainedStyleMutation::Tree(
obscura_render::TreeStyleMutation::Remove { .. }
)]
));
}
assert_eq!(
rt.evaluate("document.getElementById('added').getBoundingClientRect().width")
.unwrap()
.as_f64(),
Some(40.0)
);
assert!(rt.state.borrow().pending_style_mutations.is_empty());
}
#[cfg(feature = "render")]
#[test]
fn root_overflow_clip_preserves_cssom_scroll_range() {
+278 -13
View File
@@ -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 &registration.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(
+576 -11
View File
@@ -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!(
+33 -2
View File
@@ -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 {
+5 -3
View File
@@ -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,
+267 -5
View File
@@ -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>");