diff --git a/moli-renderer-v8/src/layout_renderer/inline_svg.rs b/moli-renderer-v8/src/layout_renderer/inline_svg.rs index 7921defe98..4cc4dbaf24 100644 --- a/moli-renderer-v8/src/layout_renderer/inline_svg.rs +++ b/moli-renderer-v8/src/layout_renderer/inline_svg.rs @@ -7,7 +7,10 @@ use std::sync::Arc; -use moli_layout::{LayoutImageResource, PaintColor, ReplacedMetrics}; +use moli_layout::{LayoutImageResource, PaintColor, ReplacedMetrics, ResolvedLayoutStyle}; +use style::color::ColorSpace; +use style::values::computed::{SVGPaint, SVGPaintKind}; +use style_traits::ToCss; use crate::{ document_runtime::DomHandle, @@ -15,7 +18,7 @@ use crate::{ }; const SVG_NAMESPACE_ATTRIBUTE: &str = " xmlns=\"http://www.w3.org/2000/svg\""; -const SERIALIZED_SOURCE_INJECTION_RESERVE: usize = 256; +const SERIALIZED_SOURCE_FIXED_INJECTION_RESERVE: usize = 512; pub(super) fn replaced_metrics(element: &Element) -> ReplacedMetrics { let metadata = moli_image::svg_image_metadata_from_root_attributes( @@ -35,11 +38,12 @@ pub(super) fn replaced_metrics(element: &Element) -> ReplacedMetrics { pub(super) fn replaced_resource( host: &DomHost, node: DomHandle, - current_color: PaintColor, - font_size: f32, + style: &ResolvedLayoutStyle, ) -> Option { - let source_limit = - moli_image::MAX_ENCODED_SVG_BYTES.saturating_sub(SERIALIZED_SOURCE_INJECTION_RESERVE); + let declarations = computed_root_declarations(style); + let source_limit = moli_image::MAX_ENCODED_SVG_BYTES + .saturating_sub(declarations.len()) + .saturating_sub(SERIALIZED_SOURCE_FIXED_INJECTION_RESERVE); let source = match host.dom().outer_html_with_limit(node, source_limit) { Ok(Some(source)) => source, Ok(None) => return None, @@ -52,7 +56,7 @@ pub(super) fn replaced_resource( return None; } }; - let Some(source) = prepare_source(source, current_color, font_size) else { + let Some(source) = prepare_source(source, &declarations) else { tracing::debug!( node = node.index(), "inline SVG serialization did not produce an SVG root" @@ -83,7 +87,7 @@ pub(super) fn replaced_resource( }) } -fn prepare_source(mut source: String, current_color: PaintColor, font_size: f32) -> Option { +fn prepare_source(mut source: String, declarations: &str) -> Option { if !source.starts_with("')?; if let Some(style_start) = source[..root_end].find(" style=\"") { let value_start = style_start + " style=\"".len(); let value_end = value_start + source[value_start..root_end].find('"')?; - source.insert_str(value_end, &format!(";{declaration}")); + source.insert_str(value_end, &format!(";{declarations}")); } else { - source.insert_str(root_end, &format!(" style=\"{declaration}\"")); + source.insert_str(root_end, &format!(" style=\"{declarations}\"")); } // HTML serialization uses this named entity while XML has no predefined @@ -127,7 +130,125 @@ fn prepare_source(mut source: String, current_color: PaintColor, font_size: f32) Some(source) } -fn computed_root_declarations(color: PaintColor, font_size: f32) -> String { +fn computed_root_declarations(style: &ResolvedLayoutStyle) -> String { + let mut declarations = inherited_context_declarations(style.current_color(), style.font_size()); + let Some(computed) = style.stylo_computed_values() else { + return declarations; + }; + + // Blink paints every SVG LayoutObject from its ComputedStyle. Moli's + // bounded usvg bridge instead serializes the live subtree, so document + // stylesheets are no longer present when usvg reparses it. Snapshot the + // root's inherited SVG paint group into that isolated document. Child + // declarations still override inherited values normally. + let current_color = computed.clone_color(); + append_svg_paint( + &mut declarations, + "fill", + &computed.clone_fill(), + ¤t_color, + ); + append_svg_paint( + &mut declarations, + "stroke", + &computed.clone_stroke(), + ¤t_color, + ); + for (name, value) in [ + ( + "fill-opacity", + computed.clone_fill_opacity().to_css_string(), + ), + ("fill-rule", computed.clone_fill_rule().to_css_string()), + ( + "stroke-opacity", + computed.clone_stroke_opacity().to_css_string(), + ), + ( + "stroke-width", + computed.clone_stroke_width().to_css_string(), + ), + ( + "stroke-dasharray", + computed.clone_stroke_dasharray().to_css_string(), + ), + ( + "stroke-dashoffset", + computed.clone_stroke_dashoffset().to_css_string(), + ), + ( + "stroke-linecap", + computed.clone_stroke_linecap().to_css_string(), + ), + ( + "stroke-linejoin", + computed.clone_stroke_linejoin().to_css_string(), + ), + ( + "stroke-miterlimit", + computed.clone_stroke_miterlimit().to_css_string(), + ), + ("clip-rule", computed.clone_clip_rule().to_css_string()), + ("paint-order", computed.clone_paint_order().to_css_string()), + ( + "shape-rendering", + computed.clone_shape_rendering().to_css_string(), + ), + ] { + append_serialized_declaration(&mut declarations, name, &value); + } + declarations +} + +fn inherited_context_declarations(color: PaintColor, font_size: f32) -> String { + let font_size = if font_size.is_finite() { + font_size.max(0.0) + } else { + 16.0 + }; + format!( + "color:{} !important;font-size:{font_size:.6}px !important;", + rgba_css(color), + ) +} + +fn append_svg_paint( + declarations: &mut String, + name: &str, + paint: &SVGPaint, + current_color: &style::color::AbsoluteColor, +) { + // A computed external paint-server URL needs document URL/resource state + // that the isolated usvg document does not own. Preserve the serialized + // authored value until that resource bridge exists. Blink resolves a + // color paint through VisitedDependentColor() immediately before drawing; + // resolve currentColor at this equivalent paint-snapshot boundary. + match &paint.kind { + SVGPaintKind::Color(color) => { + let absolute = color + .resolve_to_absolute(current_color) + .to_color_space(ColorSpace::Srgb); + let [red, green, blue, alpha] = *absolute.raw_components(); + declarations.push_str(name); + declarations.push(':'); + declarations.push_str(&rgba_css(PaintColor::new(red, green, blue, alpha))); + declarations.push_str(" !important;"); + } + SVGPaintKind::PaintServer(_) => {} + SVGPaintKind::None | SVGPaintKind::ContextFill | SVGPaintKind::ContextStroke => { + append_serialized_declaration(declarations, name, &paint.to_css_string()); + } + } +} + +fn append_serialized_declaration(declarations: &mut String, name: &str, value: &str) { + declarations.push_str(name); + declarations.push(':'); + declarations.push_str(value); + declarations.push_str(" !important;"); +} + +fn rgba_css(color: PaintColor) -> String { let channel = |value: f32| { let value = if value.is_finite() { value } else { 0.0 }; (value.clamp(0.0, 1.0) * 255.0).round() as u8 @@ -137,13 +258,8 @@ fn computed_root_declarations(color: PaintColor, font_size: f32) -> String { } else { 0.0 }; - let font_size = if font_size.is_finite() { - font_size.max(0.0) - } else { - 16.0 - }; format!( - "color:rgba({},{},{},{alpha:.6}) !important;font-size:{font_size:.6}px !important", + "rgba({},{},{},{alpha:.6})", channel(color.red), channel(color.green), channel(color.blue), @@ -156,10 +272,11 @@ mod tests { #[test] fn source_bridge_adds_xml_namespace_and_resolved_current_color() { + let declarations = + inherited_context_declarations(PaintColor::new(1.0, 0.0, 0.0, 1.0), 16.0); let source = prepare_source( "".to_owned(), - PaintColor::new(1.0, 0.0, 0.0, 1.0), - 16.0, + &declarations, ) .unwrap(); assert!(source.starts_with(" { svg: ready.svg, }) } - Some(LayoutReplacedKind::Svg) => super::inline_svg::replaced_resource( - self.host(), - node, - style.current_color(), - style.font_size(), - ), + Some(LayoutReplacedKind::Svg) => { + super::inline_svg::replaced_resource(self.host(), node, style) + } Some(LayoutReplacedKind::Canvas) => { let pixels = self.runtime.canvas_pixels_for_layout(node)?; Some(LayoutImageResource { diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/inline_svg_paint.rs b/moli-renderer-v8/src/runtime/page_vm/tests/inline_svg_paint.rs new file mode 100644 index 0000000000..5008d3626c --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/inline_svg_paint.rs @@ -0,0 +1,55 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn screenshot_projects_external_svg_root_paint_like_chromium() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/inline-svg-computed-paint.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +document.body.innerHTML = ` + +`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + + assert_eq!( + page_vm.vm_mut().eval( + "[getComputedStyle(document.getElementById('run-icon')).fill,getComputedStyle(document.getElementById('stroke')).stroke].join('|')", + )?, + "rgb(255, 255, 255)|rgb(0, 128, 0)", + "Stylo must resolve the external SVG paint rules before the paint bridge snapshots them", + ); + + let snapshot = page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(140, 50, 1.0))? + .expect("inline SVG computed-paint fixture must retain a layout root"); + let raster = moli_paint::raster_snapshot(&snapshot)?; + let pixel = |x: u32, y: u32| { + let index = ((y * raster.width + x) * 4) as usize; + <[u8; 4]>::try_from(&raster.rgba[index..index + 4]).expect("RGBA pixel") + }; + + assert_eq!(pixel(40, 20), [255, 255, 255, 255]); + assert_eq!(pixel(15, 20), [174, 67, 28, 255]); + assert_eq!(pixel(110, 20), [0, 128, 0, 255]); + assert_eq!(pixel(90, 20), [255, 255, 255, 255]); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("inline SVG computed-paint fixture should run"); +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index 9f75d2bc89..e90ec45246 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -126,6 +126,7 @@ mod hash_change_delivery; mod history_traversal; mod image_load_event; mod indexed_db; +mod inline_svg_paint; mod internal_loading_completion; mod lifecycle; mod main_document_post_parse_completion;