diff --git a/crates/obscura-browser/src/pdf.rs b/crates/obscura-browser/src/pdf.rs index 675fef1..851eb35 100644 --- a/crates/obscura-browser/src/pdf.rs +++ b/crates/obscura-browser/src/pdf.rs @@ -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()))?; diff --git a/crates/obscura-cdp/src/domains/pdf.rs b/crates/obscura-cdp/src/domains/pdf.rs index 5a55f73..0a749ff 100644 --- a/crates/obscura-cdp/src/domains/pdf.rs +++ b/crates/obscura-cdp/src/domains/pdf.rs @@ -66,10 +66,6 @@ fn parse_options(params: &Value) -> Result { )); } } - // 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 { 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(); diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index a6acc39..9bdbe12 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -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, - /// 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, + pub pending_style_mutations: Vec, /// 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 { +) -> Option { let node = NodeId::new(arg1.parse::().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)) + old_value: keeps_selector_value + .then(|| { + dom.with_node(node, |node| { + node.get_attribute(name).map(str::to_owned) + }) + .flatten() + }) .flatten(), - new_value: Some(value.to_string()), - }) + new_value: keeps_selector_value.then(|| value.to_string()), + } + .into()) } - "remove_attribute" => can_retain(arg2).then(|| obscura_render::AttributeStyleMutation { - node, - name: arg2.to_string(), - old_value: dom - .with_node(node, |node| node.get_attribute(arg2).map(str::to_owned)) - .flatten(), - new_value: None, + "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: 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::().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::().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(), diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 6431fd3..bb3bd0f 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -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, 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, 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(); - assert!( - rt.state.borrow().prepared_render.is_none(), - "a changed attribute on a connected element must invalidate layout" - ); + { + let state = rt.state.borrow(); + assert!( + 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#" +
+ "#, + ); + 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() { diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 0e59aaf..bbb223f 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -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>, classes: HashMap>, attributes: HashMap>, + local_names: HashMap>, states: HashMap>, conservative_rule_orders: Vec, + relational_rule_orders: Vec, + unkeyed_relational_rule_orders: Vec, } 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 { + let chars = compound.trim().chars().collect::>(); + 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::>(); + 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::(); + 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 = 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#"
"#); + let css = r#" + @property --private { + syntax:""; + inherits:false; + initial-value:75%; + } + @property --shared { + syntax:""; + 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( diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 6176a77..05947d8 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -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, } +/// 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, + new_parent: NodeId, + }, + Remove { + node: NodeId, + old_parent: NodeId, + }, + Text { + node: NodeId, + parent: Option, + }, +} + +/// 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 for RetainedStyleMutation { + fn from(mutation: AttributeStyleMutation) -> Self { + Self::Attribute(mutation) + } +} + +impl From 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) -> 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, +) { + 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, stylesheet_cache: Option<&mut crate::css::StylesheetCache>, retained: Option, - 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::>(); + 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#"
+ +
"#, + ); + 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#" +
"#, + ); + 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#"
a
"#, + ); + 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#" +
"# + ); + 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#" +
<{inserted_tag} id="insert">
"# + ); + 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#"
"#, + ); + 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!("")); + } + let tree = parse_html(&format!( + r#" +
"# + )); + 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!( diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 341ebe5..acab15a 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -2932,6 +2932,27 @@ impl TextEngine { clip_override: Option, 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, + 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 { diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 26815ca..1c16308 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -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, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 7176974..6d3890c 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -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 { + let mutations = mutations + .iter() + .cloned() + .map(crate::dom::RetainedStyleMutation::Attribute) + .collect::>(); + 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 { 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 { 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 { + 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 { + 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 { 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 { 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, 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::>(); + 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)>)>, + background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option)>)>, + background_conic_gradient: Option<(f32, (f32, f32), Vec<([u8; 4], Option)>)>, + background_gradient_layers: Vec, + background_image: Option, + color: Option<[u8; 4]>, + before_pseudo: Option>, + after_pseudo: Option>, +} + +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#" +
X
+
+ +
+
+ "#, + ); + 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("
");