From add39f489edd1fd1352e17be01aaf3fa8b229005 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:41:57 +0000 Subject: [PATCH] Support transparent screenshot surfaces --- crates/obscura-browser/src/page.rs | 25 +- crates/obscura-cdp/src/domains/emulation.rs | 84 ++++++ crates/obscura-cdp/src/domains/page.rs | 306 ++++++++++++++++++-- crates/obscura-js/src/lib.rs | 1 + crates/obscura-js/src/runtime.rs | 52 +++- crates/obscura-render/src/lib.rs | 11 +- crates/obscura-render/src/paint.rs | 163 ++++++++++- 7 files changed, 607 insertions(+), 35 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 50029ac..3a7ad22 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -228,6 +228,9 @@ pub struct Page { /// CSSOM stay in CSS pixels; Emulation.setDeviceMetricsOverride owns this /// independent raster scale. pub device_scale_factor: f32, + /// DevTools override for the compositor's base surface. It is page-owned, + /// so it survives document navigation without leaking to other targets. + default_background_color_override: Option<[u8; 4]>, /// WHATWG canonical name of the current document's character encoding /// (e.g. "UTF-8", "EUC-JP"), detected when the response body is decoded. /// Exposed to JS as `document.characterSet` and used for the URL query @@ -727,6 +730,7 @@ impl Page { referrer: String::new(), viewport: (1280.0, 720.0), device_scale_factor: 1.0, + default_background_color_override: None, encoding: "UTF-8".to_string(), document_timeline_origin: std::time::Instant::now(), history: Vec::new(), @@ -800,6 +804,16 @@ impl Page { } } + pub fn set_default_background_color_override(&mut self, color: Option<[u8; 4]>) { + self.default_background_color_override = color; + } + + #[cfg(feature = "render")] + fn capture_surface_color(&self) -> [u8; 4] { + self.default_background_color_override + .unwrap_or([255, 255, 255, 255]) + } + async fn do_fetch(&self, url: &Url) -> Result { #[cfg(feature = "stealth")] if let Some(ref stealth) = self.stealth_client { @@ -2672,7 +2686,11 @@ impl Page { if !js.set_animation_sample(animation_sample) { return None; } - if let Some(png) = js.screenshot_prepared(viewport, base_url) { + if let Some(png) = js.screenshot_prepared_with_surface_color( + viewport, + base_url, + self.capture_surface_color(), + ) { return Some(png); } } @@ -2684,12 +2702,13 @@ impl Page { .map(|js| js.scroll_offset()) .unwrap_or((0.0, 0.0)); self.with_dom(|dom| { - obscura_js::screenshot_png_scrolled_at_animation_time( + obscura_js::screenshot_png_scrolled_at_animation_time_with_surface_color( dom, viewport, base_url, scroll, animation_sample.time, + self.capture_surface_color(), ) }) .flatten() @@ -2734,7 +2753,7 @@ impl Page { if !js.set_animation_sample(animation_sample) { return Err(obscura_js::CaptureError::PaintFailed); } - js.screenshot_prepared_region(region) + js.screenshot_prepared_region_with_surface_color(region, self.capture_surface_color()) } /// Scrollable document dimensions from the retained render layout. Unlike diff --git a/crates/obscura-cdp/src/domains/emulation.rs b/crates/obscura-cdp/src/domains/emulation.rs index e6e07a0..945c9ed 100644 --- a/crates/obscura-cdp/src/domains/emulation.rs +++ b/crates/obscura-cdp/src/domains/emulation.rs @@ -18,6 +18,40 @@ fn metric_dimension(params: &Value, name: &str) -> Result { Ok(value as u32) } +fn default_background_color(params: &Value) -> Result, String> { + let Some(color) = params.get("color") else { + return Ok(None); + }; + let color = color.as_object().ok_or( + "Emulation.setDefaultBackgroundColorOverride color must be an RGBA object", + )?; + let channel = |name: &str| -> Result { + let value = color.get(name).and_then(Value::as_i64).ok_or_else(|| { + format!( + "Emulation.setDefaultBackgroundColorOverride requires integer color.{name}" + ) + })?; + Ok(value.clamp(0, 255) as u8) + }; + let alpha = match color.get("a") { + Some(value) => value.as_f64().ok_or( + "Emulation.setDefaultBackgroundColorOverride color.a must be a number", + )?, + None => 1.0, + }; + if !alpha.is_finite() { + return Err( + "Emulation.setDefaultBackgroundColorOverride color.a must be finite".to_string(), + ); + } + Ok(Some([ + channel("r")?, + channel("g")?, + channel("b")?, + ((alpha as f32).clamp(0.0, 1.0) * 255.0).round() as u8, + ])) +} + pub async fn handle( method: &str, params: &Value, @@ -64,6 +98,14 @@ pub async fn handle( page.set_device_scale_factor(1.0); Ok(json!({})) } + "setDefaultBackgroundColorOverride" => { + let color = default_background_color(params)?; + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; + page.set_default_background_color_override(color); + Ok(json!({})) + } // Touch emulation does not affect layout yet, but acknowledging it is // compatible with clients that pair it with a metrics override. "setTouchEmulationEnabled" => Ok(json!({})), @@ -194,4 +236,46 @@ mod tests { assert_eq!(page.viewport, (1280.0, 720.0)); assert_eq!(page.device_scale_factor, 1.0); } + + #[test] + fn default_background_color_matches_blink_defaults_rounding_and_clamping() { + assert_eq!(default_background_color(&json!({})), Ok(None)); + assert_eq!( + default_background_color(&json!({"color": {"r": 1, "g": 2, "b": 3}})), + Ok(Some([1, 2, 3, 255])) + ); + assert_eq!( + default_background_color(&json!({ + "color": {"r": -20, "g": 400, "b": 30, "a": 16.0 / 255.0} + })), + Ok(Some([0, 255, 30, 16])) + ); + assert_eq!( + default_background_color(&json!({ + "color": {"r": 1, "g": 2, "b": 3, "a": 2} + })), + Ok(Some([1, 2, 3, 255])) + ); + assert_eq!( + default_background_color(&json!({ + "color": {"r": 1, "g": 2, "b": 3, "a": -1} + })), + Ok(Some([1, 2, 3, 0])) + ); + } + + #[test] + fn default_background_color_rejects_malformed_rgba() { + for params in [ + json!({"color": null}), + json!({"color": {"g": 2, "b": 3}}), + json!({"color": {"r": 1.5, "g": 2, "b": 3}}), + json!({"color": {"r": 1, "g": 2, "b": 3, "a": "opaque"}}), + ] { + assert!( + default_background_color(¶ms).is_err(), + "must reject {params}" + ); + } + } } diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index b3aff4e..3123631 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -702,6 +702,7 @@ pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool { | "Input.dispatchTouchEvent" | "Emulation.setDeviceMetricsOverride" | "Emulation.clearDeviceMetricsOverride" + | "Emulation.setDefaultBackgroundColorOverride" | "DOM.setAttributeValue" | "DOM.removeNode" | "DOM.focus" @@ -1428,20 +1429,6 @@ pub async fn handle( }; let region = if let Some(clip) = options.clip { - let relative_x = clip.x - scroll.0; - let relative_y = clip.y - scroll.1; - let epsilon = 1e-6; - if !options.capture_beyond_viewport - && (relative_x < -epsilon - || relative_y < -epsilon - || relative_x + clip.width > f64::from(viewport.0) + epsilon - || relative_y + clip.height > f64::from(viewport.1) + epsilon) - { - return Err(format!( - "Page.captureScreenshot clip lies outside the current viewport surface while captureBeyondViewport=false (visible page rect: x={} y={} width={} height={})", - scroll.0, scroll.1, viewport.0, viewport.1 - )); - } Some(chromium_clip_region(clip, device_scale_factor)?) } else if let Some(content_size) = full_page_size { Some(obscura_browser::CaptureRegion::new( @@ -1682,6 +1669,273 @@ mod tests { (ctx, session) } + #[cfg(feature = "render")] + async fn transparent_surface_fixture( + height: u32, + ) -> (CdpContext, Option) { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = format!("{page_id}-session"); + ctx.sessions.insert(session_id.clone(), page_id); + let session = Some(session_id); + ctx.get_session_page_mut(&session) + .expect("page") + .set_viewport((100.0, 80.0)); + handle( + "navigate", + &json!({ + "url": format!("data:text/html,"), + "waitUntil": "load", + }), + &mut ctx, + &session, + ) + .await + .expect("navigate transparent surface fixture"); + (ctx, session) + } + + #[cfg(feature = "render")] + #[tokio::test] + async fn default_background_override_matches_chromium_across_capture_state() { + let (mut ctx, session) = transparent_surface_fixture(160).await; + + let (_, default_raster) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("default white capture"), + ); + assert_eq!(default_raster.get_pixel(50, 40).0, [255, 255, 255, 255]); + + crate::domains::emulation::handle( + "setDefaultBackgroundColorOverride", + &json!({"color": {"r": 0, "g": 0, "b": 255, "a": 1}}), + &mut ctx, + &session, + ) + .await + .expect("opaque blue override"); + let (_, blue) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("blue capture"), + ); + assert!(blue.pixels().all(|pixel| pixel.0 == [0, 0, 255, 255])); + + crate::domains::emulation::handle( + "setDefaultBackgroundColorOverride", + &json!({"color": {"r": 0, "g": 0, "b": 0, "a": 0}}), + &mut ctx, + &session, + ) + .await + .expect("transparent override"); + let (_, transparent) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("transparent capture"), + ); + assert!(transparent.pixels().all(|pixel| pixel.0 == [0, 0, 0, 0])); + + crate::domains::emulation::handle( + "setDefaultBackgroundColorOverride", + &json!({"color": {"r": 255, "g": 0, "b": 0, "a": 16.0 / 255.0}}), + &mut ctx, + &session, + ) + .await + .expect("semi-transparent override"); + crate::domains::emulation::handle( + "setDeviceMetricsOverride", + &json!({"width": 40, "height": 30, "deviceScaleFactor": 2}), + &mut ctx, + &session, + ) + .await + .expect("metrics override"); + let (_, semi) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("semi-transparent metrics capture"), + ); + assert_eq!(semi.dimensions(), (80, 60)); + assert!(semi.pixels().all(|pixel| pixel.0 == [255, 0, 0, 16])); + + handle( + "navigate", + &json!({ + "url": "data:text/html,", + "waitUntil": "load", + }), + &mut ctx, + &session, + ) + .await + .expect("navigate with live override"); + let (_, after_navigation) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("capture after navigation"), + ); + assert_eq!(after_navigation.get_pixel(20, 15).0, [255, 0, 0, 16]); + + crate::domains::emulation::handle( + "setDefaultBackgroundColorOverride", + &json!({}), + &mut ctx, + &session, + ) + .await + .expect("clear override"); + let (_, cleared) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("capture after clear"), + ); + assert_eq!(cleared.get_pixel(20, 15).0, [255, 255, 255, 255]); + } + + #[cfg(feature = "render")] + #[tokio::test] + async fn default_background_override_is_target_isolated() { + let (mut ctx, first_session) = transparent_surface_fixture(80).await; + let second_page_id = ctx.create_page(); + let second_session_id = format!("{second_page_id}-session"); + ctx.sessions + .insert(second_session_id.clone(), second_page_id); + let second_session = Some(second_session_id); + ctx.get_session_page_mut(&second_session) + .expect("second page") + .set_viewport((100.0, 80.0)); + handle( + "navigate", + &json!({ + "url": "data:text/html,", + "waitUntil": "load", + }), + &mut ctx, + &second_session, + ) + .await + .expect("navigate second page"); + crate::domains::emulation::handle( + "setDefaultBackgroundColorOverride", + &json!({"color": {"r": 20, "g": 40, "b": 60}}), + &mut ctx, + &first_session, + ) + .await + .expect("first-page override"); + + let (_, first) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &first_session) + .await + .expect("first-page capture"), + ); + let (_, second) = decode_capture( + &handle("captureScreenshot", &json!({}), &mut ctx, &second_session) + .await + .expect("second-page capture"), + ); + assert_eq!(first.get_pixel(50, 40).0, [20, 40, 60, 255]); + assert_eq!(second.get_pixel(50, 40).0, [255, 255, 255, 255]); + } + + #[cfg(feature = "render")] + #[tokio::test] + async fn default_background_override_covers_clips_full_page_and_screencast_damage() { + let (mut ctx, session) = transparent_surface_fixture(160).await; + crate::domains::emulation::handle( + "setDefaultBackgroundColorOverride", + &json!({"color": {"r": 7, "g": 19, "b": 31, "a": 1}}), + &mut ctx, + &session, + ) + .await + .expect("surface override"); + + for clip in [ + json!({"x": 90, "y": 0, "width": 20, "height": 20, "scale": 1}), + json!({"x": 0, "y": 120, "width": 20, "height": 20, "scale": 1}), + ] { + let (_, raster) = decode_capture( + &handle( + "captureScreenshot", + &json!({"captureBeyondViewport": false, "clip": clip}), + &mut ctx, + &session, + ) + .await + .expect("off-viewport clip with default false"), + ); + assert_eq!(raster.dimensions(), (20, 20)); + assert!(raster + .pixels() + .all(|pixel| pixel.0 == [7, 19, 31, 255])); + } + + ctx.get_session_page_mut(&session) + .expect("page") + .evaluate("window.scrollTo(0, 80)"); + let (_, above_scroll) = decode_capture( + &handle( + "captureScreenshot", + &json!({ + "clip": {"x": 0, "y": 0, "width": 20, "height": 20, "scale": 1} + }), + &mut ctx, + &session, + ) + .await + .expect("document clip above live scroll"), + ); + assert_eq!(above_scroll.get_pixel(10, 10).0, [7, 19, 31, 255]); + assert_eq!( + ctx.get_session_page(&session) + .expect("page") + .screenshot_scroll_offset(), + (0.0, 80.0), + "clip capture must not mutate live scroll" + ); + + let (_, full_page) = decode_capture( + &handle( + "captureScreenshot", + &json!({"captureBeyondViewport": true}), + &mut ctx, + &session, + ) + .await + .expect("full-page override capture"), + ); + assert_eq!(full_page.dimensions(), (100, 160)); + assert_eq!(full_page.get_pixel(50, 140).0, [7, 19, 31, 255]); + + ctx.pending_events.clear(); + handle("startScreencast", &json!({}), &mut ctx, &session) + .await + .expect("start screencast"); + ctx.pending_events.clear(); + let response = crate::dispatch::dispatch( + &crate::types::CdpRequest { + id: 99, + method: "Emulation.setDefaultBackgroundColorOverride".to_string(), + params: json!({"color": {"r": 90, "g": 80, "b": 70, "a": 1}}), + session_id: session.clone(), + }, + &mut ctx, + ) + .await; + assert!(response.error.is_none(), "override response: {response:?}"); + let event = ctx + .pending_events + .iter() + .find(|event| event.method == "Page.screencastFrame") + .expect("background override must damage active screencast"); + let (_, frame) = decode_capture(&event.params); + assert_eq!(frame.get_pixel(50, 40).0, [90, 80, 70, 255]); + } + #[cfg(feature = "render")] #[tokio::test] async fn screencast_initial_frame_metadata_and_encoding_match_options() { @@ -2122,7 +2376,7 @@ mod tests { handle( "navigate", &json!({ - "url": "data:text/html,
", + "url": "data:text/html,
", "waitUntil": "load", }), &mut ctx, @@ -2130,6 +2384,14 @@ mod tests { ) .await .expect("navigate long screenshot fixture"); + crate::domains::emulation::handle( + "setDefaultBackgroundColorOverride", + &json!({"color": {"r": 180, "g": 20, "b": 30, "a": 1}}), + &mut ctx, + &session, + ) + .await + .expect("long-page surface override"); ctx.get_session_page_mut(&session) .expect("page") .evaluate("window.scrollTo(0, 8000)"); @@ -2376,7 +2638,7 @@ mod tests { #[cfg(feature = "render")] #[tokio::test] - async fn capture_screenshot_supports_full_page_but_rejects_unrepresented_surfaces() { + async fn capture_screenshot_supports_full_page_and_off_viewport_clips() { let (mut ctx, session) = screenshot_fixture().await; ctx.get_session_page_mut(&session).expect("page").evaluate( "Object.defineProperty(globalThis,'innerWidth',{value:4096,configurable:true});\ @@ -2417,11 +2679,13 @@ mod tests { &session, ) .await - .expect_err("partial off-viewport clip is unsupported"); - assert!( - off_viewport.contains("outside the current viewport surface"), - "{off_viewport}" - ); + .expect("Chromium accepts a partial off-viewport surface clip"); + let (_, raster) = decode_capture(&off_viewport); + assert_eq!(raster.dimensions(), (20, 20)); + assert_eq!(raster.get_pixel(5, 10).0, [0, 0, 255, 255]); + // TODO(canvas-background-transfer): Chromium propagates the body's red + // canvas background into the half beyond the 100px document box. This + // regression covers acceptance and the represented clip content. } #[cfg(feature = "render")] diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index 0b5e2db..25dd435 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -14,6 +14,7 @@ pub use v8_flags::set_v8_flags; #[cfg(feature = "render")] pub use obscura_render::{ screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time, + screenshot_png_scrolled_at_animation_time_with_surface_color, validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime, CaptureError, CaptureRegion, ImageRequestProfile, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 47fbb50..9998b47 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -534,6 +534,20 @@ impl ObscuraJsRuntime { &self, viewport: (f32, f32), base_url: Option<&str>, + ) -> Option> { + self.screenshot_prepared_with_surface_color( + viewport, + base_url, + [255, 255, 255, 255], + ) + } + + #[cfg(feature = "render")] + pub fn screenshot_prepared_with_surface_color( + &self, + viewport: (f32, f32), + base_url: Option<&str>, + surface_color: [u8; 4], ) -> Option> { let mut state = self.state.borrow_mut(); let effective_base = document_base_url(&state); @@ -550,11 +564,12 @@ impl ObscuraJsRuntime { .. } = state; let (_, scroll) = resolved_scroll.as_ref()?; - obscura_render::screenshot_prepared_with_scroll( + obscura_render::screenshot_prepared_with_scroll_and_surface_color( dom.as_ref()?, prepared_render.as_mut()?, render_resources, scroll, + surface_color, ) }) } @@ -567,7 +582,40 @@ impl ObscuraJsRuntime { &self, region: obscura_render::CaptureRegion, ) -> Result, obscura_render::CaptureError> { - self.screenshot_prepared_region_with_backgrounds(region, true) + self.screenshot_prepared_region_with_surface_color(region, [255, 255, 255, 255]) + } + + #[cfg(feature = "render")] + pub fn screenshot_prepared_region_with_surface_color( + &self, + region: obscura_render::CaptureRegion, + surface_color: [u8; 4], + ) -> Result, obscura_render::CaptureError> { + let mut state = self.state.borrow_mut(); + with_sync_render_loading_disabled(&mut state, |state| { + ensure_resolved_scroll(state).ok_or(obscura_render::CaptureError::PaintFailed)?; + let ObscuraState { + dom, + prepared_render, + render_resources, + resolved_scroll, + .. + } = state; + let (_, scroll) = resolved_scroll + .as_ref() + .ok_or(obscura_render::CaptureError::PaintFailed)?; + obscura_render::screenshot_prepared_region_with_scroll_and_surface_color( + dom.as_ref() + .ok_or(obscura_render::CaptureError::PaintFailed)?, + prepared_render + .as_mut() + .ok_or(obscura_render::CaptureError::PaintFailed)?, + render_resources, + scroll, + region, + surface_color, + ) + }) } /// Capture a document-space rectangle with the PDF print-background diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index e50e29a..dd554c4 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -98,20 +98,25 @@ mod paint; #[cfg(feature = "paint")] pub use paint::{ image_intrinsic_dimensions, paint_dom, paint_dom_scrolled, - paint_dom_scrolled_at_animation_time, paint_prepared, - paint_prepared_region_with_scroll, - paint_prepared_with_scroll, prepare_dom, prepare_dom_at_animation_time, + paint_dom_scrolled_at_animation_time, + paint_dom_scrolled_at_animation_time_with_surface_color, paint_prepared, + paint_prepared_region_with_scroll, paint_prepared_region_with_scroll_and_surface_color, + paint_prepared_with_scroll, paint_prepared_with_scroll_and_surface_color, prepare_dom, + prepare_dom_at_animation_time, prepare_dom_with_dynamic_fonts, prepare_dom_with_dynamic_fonts_at_animation_time, prepare_dom_with_dynamic_fonts_and_stylesheet_cache, prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time, prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state, screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time, + screenshot_png_scrolled_at_animation_time_with_surface_color, prepare_dom_with_retained_attribute_styles, prepare_dom_with_retained_styles, prepare_dom_with_retained_styles_at_animation_time, prepare_dom_with_retained_styles_with_animation_state, screenshot_prepared, screenshot_prepared_region_with_scroll, + screenshot_prepared_region_with_scroll_and_surface_color, screenshot_prepared_region_with_scroll_and_backgrounds, screenshot_prepared_with_scroll, + screenshot_prepared_with_scroll_and_surface_color, validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics, ImageRequestProfile, PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 07f7631..1e2e4b1 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -1826,6 +1826,24 @@ pub fn paint_dom_scrolled_at_animation_time( base_url: Option<&str>, scroll: (f32, f32), animation_sample_time: crate::AnimationSampleTime, +) -> Option { + paint_dom_scrolled_at_animation_time_with_surface_color( + tree, + viewport, + base_url, + scroll, + animation_sample_time, + [255, 255, 255, 255], + ) +} + +pub fn paint_dom_scrolled_at_animation_time_with_surface_color( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + scroll: (f32, f32), + animation_sample_time: crate::AnimationSampleTime, + surface_color: [u8; 4], ) -> Option { let mut resources = RenderResourceCache::default(); let mut prepared = prepare_dom_at_animation_time( @@ -1835,7 +1853,7 @@ pub fn paint_dom_scrolled_at_animation_time( &mut resources, animation_sample_time, )?; - paint_prepared(tree, &mut prepared, &mut resources, scroll) + paint_prepared_with_surface_color(tree, &mut prepared, &mut resources, scroll, surface_color) } /// Resolve image candidates and web fonts, then create the single final layout @@ -2246,6 +2264,16 @@ pub fn paint_prepared( prepared: &mut PreparedRender, resources: &mut RenderResourceCache, scroll: (f32, f32), +) -> Option { + paint_prepared_with_surface_color(tree, prepared, resources, scroll, [255, 255, 255, 255]) +} + +fn paint_prepared_with_surface_color( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: (f32, f32), + surface_color: [u8; 4], ) -> Option { validate_capture_region(CaptureRegion::new( scroll.0, @@ -2257,7 +2285,12 @@ pub fn paint_prepared( .ok()?; let (w, h) = (prepared.viewport.0 as u32, prepared.viewport.1 as u32); let mut pixmap = Pixmap::new(w, h)?; - pixmap.fill(Color::WHITE); + pixmap.fill(Color::from_rgba8( + surface_color[0], + surface_color[1], + surface_color[2], + surface_color[3], + )); paint_laid_dom_scrolled( tree, prepared.viewport, @@ -2291,6 +2324,22 @@ pub fn paint_prepared_with_scroll( prepared: &mut PreparedRender, resources: &mut RenderResourceCache, scroll: &ResolvedScrollState, +) -> Option { + paint_prepared_with_scroll_and_surface_color( + tree, + prepared, + resources, + scroll, + [255, 255, 255, 255], + ) +} + +pub fn paint_prepared_with_scroll_and_surface_color( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, + surface_color: [u8; 4], ) -> Option { validate_capture_region(CaptureRegion::new( scroll.root_offset().0, @@ -2302,7 +2351,12 @@ pub fn paint_prepared_with_scroll( .ok()?; let (w, h) = (prepared.viewport.0 as u32, prepared.viewport.1 as u32); let mut pixmap = Pixmap::new(w, h)?; - pixmap.fill(Color::WHITE); + pixmap.fill(Color::from_rgba8( + surface_color[0], + surface_color[1], + surface_color[2], + surface_color[3], + )); paint_laid_dom_scrolled( tree, prepared.viewport, @@ -2340,7 +2394,33 @@ pub fn paint_prepared_region_with_scroll( scroll: &ResolvedScrollState, region: CaptureRegion, ) -> Result { - paint_prepared_region_with_scroll_policy(tree, prepared, resources, scroll, region, false) + paint_prepared_region_with_scroll_and_surface_color( + tree, + prepared, + resources, + scroll, + region, + [255, 255, 255, 255], + ) +} + +pub fn paint_prepared_region_with_scroll_and_surface_color( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, + region: CaptureRegion, + surface_color: [u8; 4], +) -> Result { + paint_prepared_region_with_scroll_policy( + tree, + prepared, + resources, + scroll, + region, + false, + surface_color, + ) } fn paint_prepared_region_with_scroll_with_print_economy( @@ -2350,7 +2430,15 @@ fn paint_prepared_region_with_scroll_with_print_economy( scroll: &ResolvedScrollState, region: CaptureRegion, ) -> Result { - paint_prepared_region_with_scroll_policy(tree, prepared, resources, scroll, region, true) + paint_prepared_region_with_scroll_policy( + tree, + prepared, + resources, + scroll, + region, + true, + [255, 255, 255, 255], + ) } fn paint_prepared_region_with_scroll_policy( @@ -2360,6 +2448,7 @@ fn paint_prepared_region_with_scroll_policy( scroll: &ResolvedScrollState, region: CaptureRegion, print_economy: bool, + surface_color: [u8; 4], ) -> Result { let (native_width, native_height, output_width, output_height) = checked_capture_dimensions(region)?; @@ -2377,7 +2466,12 @@ fn paint_prepared_region_with_scroll_policy( }; let mut pixmap = Pixmap::new(paint_width, paint_height).ok_or(CaptureError::AllocationLimitExceeded)?; - pixmap.fill(Color::WHITE); + pixmap.fill(Color::from_rgba8( + surface_color[0], + surface_color[1], + surface_color[2], + surface_color[3], + )); // Resolved node movement already contains `-root_scroll` for ordinary // document content and zero root movement for fixed content. Adding the @@ -5278,6 +5372,25 @@ pub fn screenshot_png_scrolled_at_animation_time( .and_then(|pixmap| pixmap.encode_png().ok()) } +pub fn screenshot_png_scrolled_at_animation_time_with_surface_color( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + scroll: (f32, f32), + animation_sample_time: crate::AnimationSampleTime, + surface_color: [u8; 4], +) -> Option> { + paint_dom_scrolled_at_animation_time_with_surface_color( + tree, + viewport, + base_url, + scroll, + animation_sample_time, + surface_color, + ) + .and_then(|pixmap| pixmap.encode_png().ok()) +} + /// PNG convenience wrapper for a retained resource-aware layout. pub fn screenshot_prepared( tree: &DomTree, @@ -5301,6 +5414,24 @@ pub fn screenshot_prepared_with_scroll( .ok() } +pub fn screenshot_prepared_with_scroll_and_surface_color( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, + surface_color: [u8; 4], +) -> Option> { + paint_prepared_with_scroll_and_surface_color( + tree, + prepared, + resources, + scroll, + surface_color, + )? + .encode_png() + .ok() +} + /// PNG convenience wrapper for [`paint_prepared_region_with_scroll`]. pub fn screenshot_prepared_region_with_scroll( tree: &DomTree, @@ -5314,6 +5445,26 @@ pub fn screenshot_prepared_region_with_scroll( .map_err(|_| CaptureError::EncodeFailed) } +pub fn screenshot_prepared_region_with_scroll_and_surface_color( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, + region: CaptureRegion, + surface_color: [u8; 4], +) -> Result, CaptureError> { + paint_prepared_region_with_scroll_and_surface_color( + tree, + prepared, + resources, + scroll, + region, + surface_color, + )? + .encode_png() + .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;