diff --git a/.claude/launch.json b/.claude/launch.json index a2df7b8b6..1d04c2a3c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,18 @@ "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "editor", "dev"], "port": 3000 + }, + { + "name": "svg-reftest-viewer", + "runtimeExecutable": "python3", + "runtimeArgs": [ + "-m", + "http.server", + "8123", + "--directory", + "target/reftests/viewer" + ], + "port": 8123 } ] } diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index fb7d152f8..38632ac50 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -505,9 +505,176 @@ fn detect_img_element(node: &DemoNode) -> ReplacedContent { attr_height, object_fit: types::ObjectFit::Fill, // HTML spec default for object_position: BackgroundPosition::center(), + svg_xml: None, + svg_view_box: None, } } +// ─── Inline detection ──────────────────────────────────────── + +/// XML-serialize an SVG subtree into a self-contained `` string. +/// +/// Inspired by Servo's `SVGSVGElement::serialize_and_cache_subtree` +/// (components/script/dom/svg/svgsvgelement.rs): the `` subtree is +/// flattened to a standalone XML string so it can be parsed by an +/// out-of-band SVG renderer. Unlike Servo, Grida hands the string to +/// Skia's built-in `svg::Dom` (GPU-capable) rather than resvg's +/// tiny-skia rasterizer. +fn serialize_svg_subtree(dom: &DemoDom, svg_node: &DemoNode) -> String { + let mut out = String::new(); + write_svg_element(dom, svg_node, &mut out, /*inject_xmlns=*/ true); + out +} + +fn write_svg_node(dom: &DemoDom, node: &DemoNode, out: &mut String) { + match &node.data { + DemoNodeData::Element(_) => write_svg_element(dom, node, out, false), + DemoNodeData::Text(t) => escape_xml_text(t.as_ref(), out), + // Comments, doctypes, PIs, the document node — skip entirely. + _ => {} + } +} + +fn write_svg_element(dom: &DemoDom, node: &DemoNode, out: &mut String, inject_xmlns: bool) { + let DemoNodeData::Element(ref data) = node.data else { + return; + }; + let tag: &str = &data.name.local; + + out.push('<'); + out.push_str(tag); + + // Emit attributes. + let mut has_xmlns = false; + for attr in &data.attrs { + let name: &str = &attr.name.local; + if name == "xmlns" { + has_xmlns = true; + } + out.push(' '); + out.push_str(name); + out.push_str("=\""); + escape_xml_attr(&attr.value, out); + out.push('"'); + } + + // Skia's parser requires the SVG namespace on the root element. + if inject_xmlns && !has_xmlns { + out.push_str(" xmlns=\"http://www.w3.org/2000/svg\""); + } + + if node.children.is_empty() { + out.push_str("/>"); + return; + } + + out.push('>'); + for child_id in &node.children { + let child = dom.node(*child_id); + write_svg_node(dom, child, out); + } + out.push_str("'); +} + +fn escape_xml_text(s: &str, out: &mut String) { + for ch in s.chars() { + match ch { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + c => out.push(c), + } + } +} + +fn escape_xml_attr(s: &str, out: &mut String) { + for ch in s.chars() { + match ch { + '<' => out.push_str("<"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + c => out.push(c), + } + } +} + +fn parse_view_box(s: &str) -> Option<(f32, f32, f32, f32)> { + let parts: Vec = s + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|t| !t.is_empty()) + .filter_map(|t| t.parse::().ok()) + .collect(); + if parts.len() == 4 { + Some((parts[0], parts[1], parts[2], parts[3])) + } else { + None + } +} + +/// Capture an inline `` as a replaced element. +/// +/// Walks the subtree to produce a standalone XML document, reads +/// `width` / `height` / `viewBox` attributes for intrinsic sizing, and +/// stashes the serialized source on `ReplacedContent` for paint-time +/// delegation to `skia_safe::svg::Dom`. +fn detect_svg_element(dom: &DemoDom, node: &DemoNode) -> ReplacedContent { + let xml = serialize_svg_subtree(dom, node); + + // Intrinsic dimensions. Prefer explicit pixel width/height attrs; + // fall back to viewBox (paint / layout derive aspect ratio from it). + let attr_width = get_element_attr(node, "width") + .as_deref() + .and_then(parse_svg_length_as_px); + let attr_height = get_element_attr(node, "height") + .as_deref() + .and_then(parse_svg_length_as_px); + let svg_view_box = get_element_attr(node, "viewBox") + .as_deref() + .and_then(parse_view_box); + + ReplacedContent { + src: String::new(), + alt: None, + attr_width, + attr_height, + object_fit: types::ObjectFit::Fill, + object_position: BackgroundPosition::center(), + svg_xml: Some(xml), + svg_view_box, + } +} + +/// Parse an SVG length attribute as pixels. +/// +/// SVG `width` / `height` accept lengths with unit suffixes (`px`, +/// `pt`, `em`, …) and bare numbers (treated as user units == px). For +/// intrinsic-size resolution we only need integer-ish px values; other +/// units fall through to `None` and the caller uses `viewBox` or the +/// 300×150 default. +fn parse_svg_length_as_px(s: &str) -> Option { + let trimmed = s.trim(); + // Strip a trailing "px" if present (case-insensitive). + let numeric = if let Some(stripped) = trimmed + .strip_suffix("px") + .or_else(|| trimmed.strip_suffix("PX")) + { + stripped.trim() + } else if trimmed + .chars() + .last() + .map(|c| c.is_alphabetic() || c == '%') + .unwrap_or(false) + { + // em, pt, %, etc. — leave to viewBox. + return None; + } else { + trimmed + }; + numeric.parse::().ok().map(|v| v.max(0.0) as u32) +} + // ─── Widget (form control) detection ──────────────────────────────── /// Returns `true` for void elements (like ` is a void element } + "svg" => { + // Treat inline as a replaced element whose content is + // an XML-serialized subtree. Paint time delegates to + // skia_safe::svg::Dom (see htmlcss/paint.rs). Children are + // captured in the serialized XML — the normal DOM walker + // must not descend into them (they'd be painted twice and + // misinterpreted as HTML text nodes). + el.replaced = Some(detect_svg_element(dom, node_data)); + if el.display == types::Display::Inline { + el.display = types::Display::InlineBlock; + } + true // children are captured in svg_xml + } "input" => { detect_input_widget(node_data, el); true //

hi

"; + assert!(render_any(html, 200.0, 100.0, &fonts, &NoImages).is_ok()); + } + + // ── Inline tests ── + + /// Verify inline is collected as a replaced element with its + /// subtree serialized to XML. Children are NOT recursed into as + /// HTML — they belong to the serialized `svg_xml`. + #[test] + fn test_svg_inline_collection() { + let _guard = crate::stylo_test::lock(); + let html = r#"
"#; + let root = collect::collect_styled_tree(html).unwrap().unwrap(); + + fn find_svg(el: &style::StyledElement) -> Option<&style::ReplacedContent> { + if let Some(ref r) = el.replaced { + if r.svg_xml.is_some() { + return Some(r); + } + } + for child in &el.children { + if let style::StyledNode::Element(e) = child { + if let Some(found) = find_svg(e) { + return Some(found); + } + } + } + None + } + + let r = find_svg(&root).expect("should find replaced content"); + assert_eq!(r.attr_width, Some(100)); + assert_eq!(r.attr_height, Some(50)); + assert_eq!(r.svg_view_box, Some((0.0, 0.0, 100.0, 50.0))); + let xml = r.svg_xml.as_ref().unwrap(); + assert!( + xml.contains(" renders end-to-end through the HtmlCss pipeline. + /// Skia's built-in svg::Dom must accept the serialized subtree. + #[test] + fn test_svg_inline_render() { + let _guard = crate::stylo_test::lock(); + let fonts = test_fonts(); + let pic = test_render( + r#"
"#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok(), "inline should render: {:?}", pic.err()); + let h = pic.unwrap().cull_rect().height(); + assert!(h > 0.0, "should have positive height, got {h}"); + } + + /// Malformed SVG must not crash rendering — the container box still + /// draws (background / borders / placeholder), matching the + /// graceful-degradation pattern used for missing `` resources. + #[test] + fn test_svg_inline_malformed_recovers() { + let _guard = crate::stylo_test::lock(); + let fonts = test_fonts(); + let pic = test_render( + r#"
not valid & mismatched
"#, + 200.0, + 200.0, + &fonts, + ); + assert!(pic.is_ok(), "malformed SVG must not crash the pipeline"); + } + /// Verify background-image: url() doesn't crash with NoImages. #[test] fn test_background_image_url_placeholder() { diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs index ae66c2bbc..1ffc03ea2 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -789,7 +789,29 @@ fn paint_replaced( canvas.clip_rect(dest_rect, ClipOp::Intersect, true); } - if let Some(image) = images.get(&content.src) { + // Inline : delegate to Skia's built-in SVG DOM. Mirrors + // Servo's " as replaced element with serialized subtree" pattern + // (components/script/dom/svg/svgsvgelement.rs + + // components/net/image_cache.rs) but swaps resvg + tiny-skia for + // skia_safe::svg::Dom, which paints straight onto the SkCanvas. + let svg_handled = if let Some(ref xml) = content.svg_xml { + paint_inline_svg(canvas, xml.as_bytes(), w, h) + } else { + false + }; + + if svg_handled { + canvas.restore(); + return; + } + + // Image path: only applies to -style replaced elements (not SVG). + let image_opt = if content.svg_xml.is_none() { + images.get(&content.src) + } else { + None + }; + if let Some(image) = image_opt { let img_w = image.width() as f32; let img_h = image.height() as f32; @@ -848,6 +870,27 @@ fn paint_replaced( canvas.restore(); } +/// Render a serialized inline SVG subtree via Skia's built-in SVG DOM. +/// +/// The caller has already translated the canvas to the replaced +/// element's top-left and clipped to its content box. Returns `true` on +/// successful render, `false` if the XML fails to parse. +/// +/// Container-size semantics match Chromium's `SVGImageForContainer`: +/// the `` is rendered at the replaced element's box size, and +/// `viewBox` + `preserveAspectRatio` (interpreted internally by Skia) +/// determine how SVG user units map into that box. +fn paint_inline_svg(canvas: &Canvas, xml: &[u8], w: f32, h: f32) -> bool { + use skia_safe::{svg, FontMgr, Size}; + let data = skia_safe::Data::new_copy(xml); + let Ok(mut dom) = svg::Dom::from_bytes(&data, FontMgr::default()) else { + return false; + }; + dom.set_container_size(Size::new(w, h)); + dom.render(canvas); + true +} + /// Map CSS `image-rendering` to Skia `SamplingOptions`. /// - `Auto` → bilinear filtering (the documented default; Skia's /// `SamplingOptions::default()` is actually `Nearest`, so we spell diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs index f6dcc7856..604140636 100644 --- a/crates/grida-canvas/src/htmlcss/style.rs +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -197,6 +197,19 @@ pub struct ReplacedContent { /// `BackgroundPosition::center()` so the image is centered by /// default rather than pinned to the top-left. pub object_position: BackgroundPosition, + /// Inline SVG subtree captured at collect time. + /// + /// When `Some`, the replaced element is an `` whose content has + /// been XML-serialized (with `xmlns="http://www.w3.org/2000/svg"` + /// injected if missing). Paint time parses this via + /// `skia_safe::svg::Dom::from_bytes` and renders it onto the canvas. + /// Follows the Servo-style architectural pattern of treating inline + /// SVG as a replaced element, but uses Skia's built-in SVG module + /// (GPU-capable) instead of resvg + tiny-skia. + pub svg_xml: Option, + /// Parsed `viewBox="min-x min-y width height"` for SVG intrinsic + /// aspect-ratio resolution. Only populated when `svg_xml` is set. + pub svg_view_box: Option<(f32, f32, f32, f32)>, } /// Consecutive inline items merged into a single paragraph. diff --git a/crates/grida-dev/src/reftest/args.rs b/crates/grida-dev/src/reftest/args.rs index 62046e98a..f31af209c 100644 --- a/crates/grida-dev/src/reftest/args.rs +++ b/crates/grida-dev/src/reftest/args.rs @@ -18,6 +18,43 @@ impl std::str::FromStr for BgColor { } } +/// Choice of SVG renderer backend. +/// +/// - `Iosvg` (default): current path — parse SVG via vendored usvg, +/// convert to the Grida scene graph through `cg::svg::pack`, render +/// via the canvas runtime. Lossy (editor-oriented tree surgery), but +/// GPU-native and consistent with the in-editor experience. +/// - `Htmlcss`: goes through `cg::htmlcss::render_svg`, which records +/// into a Skia `Picture` via `PictureRecorder` before rasterizing. +/// Exercises the exact code path that inline `` inside HTML +/// takes. +/// - `Sksvg`: **minimal** direct path — `skia_safe::svg::Dom::from_bytes` +/// → `surface.canvas()` → `dom.render()`. No htmlcss module, no +/// Picture recording, no Grida tree surgery. Used to isolate Skia's +/// native SVG module so that any failure is attributable to Skia +/// itself, not our wrapping / plumbing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SvgRenderer { + Iosvg, + Htmlcss, + Sksvg, +} + +impl std::str::FromStr for SvgRenderer { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "iosvg" | "grida" | "pack" => Ok(SvgRenderer::Iosvg), + "htmlcss" => Ok(SvgRenderer::Htmlcss), + "sksvg" | "skia-svg" | "skia_svg" | "skiasvg" | "skia" => Ok(SvgRenderer::Sksvg), + other => Err(format!( + "invalid renderer: {} (use iosvg|htmlcss|sksvg)", + other + )), + } + } +} + #[derive(Args, Debug)] pub(crate) struct ReftestArgs { /// Path to W3C_SVG_11_TestSuite directory @@ -52,4 +89,13 @@ pub(crate) struct ReftestArgs { #[arg(long = "overwrite", action = clap::ArgAction::SetTrue)] #[arg(long = "no-overwrite", action = clap::ArgAction::SetFalse, overrides_with = "overwrite")] pub overwrite: Option, + + /// SVG renderer backend: + /// - `iosvg` (default): cg scene graph via usvg → pack. + /// - `htmlcss`: cg::htmlcss::render_svg → PictureRecorder → surface. + /// - `sksvg`: direct Skia svg::Dom → surface (no htmlcss wrapping). + /// Use this to prove a failure is Skia's own SVG module, not our + /// plumbing. Aliases: `skia-svg`, `skia_svg`, `skia`. + #[arg(long = "renderer", default_value = "iosvg")] + pub renderer: SvgRenderer, } diff --git a/crates/grida-dev/src/reftest/render.rs b/crates/grida-dev/src/reftest/render.rs index 5d071575c..d5afcfb95 100644 --- a/crates/grida-dev/src/reftest/render.rs +++ b/crates/grida-dev/src/reftest/render.rs @@ -150,6 +150,158 @@ pub(crate) fn find_test_pairs_in_dirs(svg_dir: &Path, png_dir: &Path) -> Result< Ok(pairs) } +/// Render an SVG file through the new htmlcss → `skia_safe::svg::Dom` +/// path and write a PNG. +/// +/// Unlike [`render_svg_to_png`], which round-trips through the Grida +/// scene graph via `cg::svg::pack`, this path delegates directly to +/// Skia's SVG module — matching Chromium's rendering for WPT-style +/// reftests. Target size is the reference PNG's pixel dimensions; +/// `viewBox` + `preserveAspectRatio` inside the SVG map user units to +/// that box. +pub(crate) fn render_svg_to_png_via_htmlcss( + svg_path: &Path, + output_path: &Path, + target_size: Option<(u32, u32)>, +) -> Result<()> { + use skia_safe::{surfaces, Color as SkColor, EncodedImageFormat as SkFmt}; + + let svg_source = fs::read_to_string(svg_path) + .with_context(|| format!("failed to read SVG file {}", svg_path.display()))?; + + // Resolve output size. When the reference PNG dimensions are known, + // use them; otherwise sniff the SVG root for a `width`/`height` or + // `viewBox` and fall back to 512×512. + let (width, height) = match target_size { + Some((w, h)) => (w.max(1) as i32, h.max(1) as i32), + None => { + let (w, h) = sniff_svg_dimensions(&svg_source).unwrap_or((512, 512)); + (w as i32, h as i32) + } + }; + + // Record the SVG into a Skia Picture via the htmlcss module's public + // entry point. + let picture = cg::htmlcss::render_svg(&svg_source, width as f32, height as f32) + .map_err(|e| anyhow!("htmlcss::render_svg failed: {e}"))?; + + // Rasterize the Picture onto a CPU-backed surface. Transparent clear + // lets the reftest's background masking (`bg = white|black`) composite + // consistently with the other renderer. + let mut surface = surfaces::raster_n32_premul((width, height)) + .ok_or_else(|| anyhow!("failed to create raster surface {}x{}", width, height))?; + { + let canvas = surface.canvas(); + canvas.clear(SkColor::TRANSPARENT); + canvas.draw_picture(&picture, None, None); + } + + let image = surface.image_snapshot(); + let data = image + .encode(None, SkFmt::PNG, None) + .ok_or_else(|| anyhow!("Failed to encode PNG"))?; + + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create output directory {}", parent.display()))?; + } + fs::write(output_path, data.as_bytes()) + .with_context(|| format!("failed to write PNG to {}", output_path.display()))?; + + Ok(()) +} + +/// Best-effort extraction of explicit `width="NNN"` / `height="NNN"` +/// attributes from the root `` element. Used only when no target +/// size is provided. Any non-integer or unit-bearing length falls back +/// to the caller's default; we do not attempt full SVG length resolution +/// here. +fn sniff_svg_dimensions(svg: &str) -> Option<(u32, u32)> { + let open = svg.find("')?; + let tag = &open[..tag_end]; + fn attr(tag: &str, name: &str) -> Option { + let needle = format!("{}=", name); + let start = tag.find(&needle)? + needle.len(); + let rest = &tag[start..]; + let (quote, rest) = rest.split_at(1); + let quote = quote.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let end = rest.find(quote)?; + let raw = &rest[..end]; + let numeric_end = raw + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(raw.len()); + raw[..numeric_end].parse::().ok().map(|v| v as u32) + } + let w = attr(tag, "width")?; + let h = attr(tag, "height")?; + Some((w.max(1), h.max(1))) +} + +/// Render an SVG file through **Skia's native SVG module** with the +/// thinnest possible wrapper. +/// +/// Pipeline: `fs::read` → `Data::new_copy` → `svg::Dom::from_bytes` → +/// `surface.canvas()` → `dom.render()` → PNG. No htmlcss module, no +/// `PictureRecorder`, no Grida tree surgery. +/// +/// Purpose: attribute reftest failures correctly. A test that fails +/// here is a limitation of Skia's own `svg::Dom` implementation, not +/// of Grida's wiring. If the same test fails identically under +/// `--renderer htmlcss`, then our htmlcss wrapping adds zero +/// divergence; any delta between the two backends attributes to the +/// `PictureRecorder` round-trip we add in htmlcss. +pub(crate) fn render_svg_to_png_via_sksvg( + svg_path: &Path, + output_path: &Path, + target_size: Option<(u32, u32)>, +) -> Result<()> { + use skia_safe::{surfaces, svg, Color as SkColor, Data, EncodedImageFormat, FontMgr, Size}; + + let svg_bytes = fs::read(svg_path) + .with_context(|| format!("failed to read SVG file {}", svg_path.display()))?; + + let (width, height) = match target_size { + Some((w, h)) => (w.max(1) as i32, h.max(1) as i32), + None => { + let svg_source = std::str::from_utf8(&svg_bytes) + .with_context(|| format!("SVG file {} is not UTF-8", svg_path.display()))?; + let (w, h) = sniff_svg_dimensions(svg_source).unwrap_or((512, 512)); + (w as i32, h as i32) + } + }; + + let data = Data::new_copy(&svg_bytes); + let mut dom = svg::Dom::from_bytes(&data, FontMgr::default()) + .map_err(|e| anyhow!("skia svg::Dom::from_bytes failed: {e}"))?; + dom.set_container_size(Size::new(width as f32, height as f32)); + + let mut surface = surfaces::raster_n32_premul((width, height)) + .ok_or_else(|| anyhow!("failed to create raster surface {}x{}", width, height))?; + { + let canvas = surface.canvas(); + canvas.clear(SkColor::TRANSPARENT); + dom.render(canvas); + } + + let image = surface.image_snapshot(); + let encoded = image + .encode(None, EncodedImageFormat::PNG, None) + .ok_or_else(|| anyhow!("Failed to encode PNG"))?; + + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create output directory {}", parent.display()))?; + } + fs::write(output_path, encoded.as_bytes()) + .with_context(|| format!("failed to write PNG to {}", output_path.display()))?; + + Ok(()) +} + pub(crate) fn render_svg_to_png( svg_path: &Path, output_path: &Path, diff --git a/crates/grida-dev/src/reftest/runner.rs b/crates/grida-dev/src/reftest/runner.rs index 320510106..d46620c8c 100644 --- a/crates/grida-dev/src/reftest/runner.rs +++ b/crates/grida-dev/src/reftest/runner.rs @@ -1,8 +1,9 @@ -use crate::reftest::args::{BgColor, ReftestArgs}; +use crate::reftest::args::{BgColor, ReftestArgs, SvgRenderer}; use crate::reftest::compare::{compare_images, ScoringMask}; use crate::reftest::config::ReftestToml; use crate::reftest::render::{ - find_test_pairs_from_glob, find_test_pairs_in_dirs, render_svg_to_png, TestPair, + find_test_pairs_from_glob, find_test_pairs_in_dirs, render_svg_to_png, + render_svg_to_png_via_htmlcss, render_svg_to_png_via_sksvg, TestPair, }; use crate::reftest::report::{generate_json_report, ReftestReport, TestResult}; use anyhow::{Context, Result}; @@ -62,7 +63,15 @@ pub(crate) async fn run_reftest(args: &ReftestArgs) -> Result<()> { .map(|s| s.to_string()) }); if let Some(name) = suite_name { - output_dir = output_dir.join(sanitize_dir_name(&name)); + // Keep iosvg (legacy default) at the historical path so + // existing CI / bookmarks don't break; qualify alt backends + // with their label. + let suffix = match args.renderer { + SvgRenderer::Iosvg => String::new(), + SvgRenderer::Htmlcss => ".htmlcss".to_string(), + SvgRenderer::Sksvg => ".sksvg".to_string(), + }; + output_dir = output_dir.join(format!("{}{}", sanitize_dir_name(&name), suffix)); } } @@ -203,11 +212,19 @@ pub(crate) async fn run_reftest(args: &ReftestArgs) -> Result<()> { // Render SVG to PNG (temporary location first) let temp_output_png = output_dir.join(format!("{}-temp-output.png", pair.test_name)); - // Render SVG to PNG, scaling to match reference size - // Wrap in catch_unwind to handle panics from usvg library + // Render SVG to PNG, scaling to match reference size. + // Wrap in catch_unwind to handle panics from underlying + // rendering code (usvg for `iosvg`, Skia for `htmlcss`). let svg_path_for_panic = pair.svg_path.clone(); - let render_result = panic::catch_unwind(panic::AssertUnwindSafe(|| { - render_svg_to_png(&pair.svg_path, &temp_output_png, target_size) + let renderer = args.renderer; + let render_result = panic::catch_unwind(panic::AssertUnwindSafe(|| match renderer { + SvgRenderer::Iosvg => render_svg_to_png(&pair.svg_path, &temp_output_png, target_size), + SvgRenderer::Htmlcss => { + render_svg_to_png_via_htmlcss(&pair.svg_path, &temp_output_png, target_size) + } + SvgRenderer::Sksvg => { + render_svg_to_png_via_sksvg(&pair.svg_path, &temp_output_png, target_size) + } })); let render_result = match render_result { diff --git a/docs/tags.yml b/docs/tags.yml index dde4bb278..6eabac67a 100644 --- a/docs/tags.yml +++ b/docs/tags.yml @@ -176,6 +176,16 @@ chromium: permalink: /chromium description: Chromium / Blink / cc compositor source research notes. +servo: + label: Servo + permalink: /servo + description: Servo browser engine source research notes. + +resvg: + label: resvg + permalink: /resvg + description: resvg / usvg SVG renderer source research notes. + compositing: label: Compositing permalink: /compositing diff --git a/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md index 71a7bcfc2..255663e2c 100644 --- a/docs/wg/feat-2d/htmlcss.md +++ b/docs/wg/feat-2d/htmlcss.md @@ -15,6 +15,44 @@ Renders HTML+CSS to a Skia Picture for opaque embedding on the canvas **Source:** `crates/grida-canvas/src/htmlcss/` +## Inputs + +Two public entry points, both returning a `skia_safe::Picture`: + +| Entry point | Input | Pipeline | +| ------------ | -------------------------------------------------- | ------------------------------------------------------------- | +| `render` | HTML source | Stylo cascade → Taffy layout → Skia paint | +| `render_svg` | Standalone SVG source | Skia's built-in `svg::Dom::from_bytes` → `dom.render(canvas)` | +| `render_any` | HTML or SVG (sniffed from `` inside HTML is also supported via the same Skia `svg::Dom` +path — see [Inline SVG](#inline-svg) below. + +### Why accept raw SVG + +Mirrors Servo's "SVG as a replaced element + serialized subtree" design +(`servo/components/script/dom/svg/svgsvgelement.rs` + +`servo/components/net/image_cache.rs`) but swaps the resvg/tiny-skia CPU +rasterizer for Skia's built-in SVG DOM — GPU-capable and paints straight +to an `SkCanvas`. Unblocks WPT-style SVG reftests without writing a +native Grida SVG renderer. + +Baseline against `fixtures/local/resvg-test-suite` (1,679 tests, `grida-dev +reftest --renderer htmlcss`): + +| Bucket | Count | % of suite | +| --------- | ----: | ---------: | +| S99 (≥99) | 482 | 28.7% | +| S95 (≥95) | 59 | 3.5% | +| S90 (≥90) | 73 | 4.3% | +| S75 (≥75) | 225 | 13.4% | +| `` paint server semantics, Chromium/resvg/Skia comparison | +| [svg/index.md](./svg/index.md) | **SVG rendering subdirectory** — full pipeline, coordinate systems, paint servers, effects, text | | [blink-rendering-pipeline.md](./blink-rendering-pipeline.md) | Blink Style → Layout → Paint pipeline, ComputedStyle groups, LayoutNG, inline layout, list markers | | [external-resource-loading.md](./external-resource-loading.md) | Resource fetch lifecycle, ImageResource observer pattern, CSS background-image/img loading pipeline | | [dirty-flag-management.md](./dirty-flag-management.md) | Dirty-flag families across Blink + cc, categorized by type and by invalidation shape/granularity | diff --git a/docs/wg/research/chromium/svg/comparison.md b/docs/wg/research/chromium/svg/comparison.md new file mode 100644 index 000000000..3681b8bda --- /dev/null +++ b/docs/wg/research/chromium/svg/comparison.md @@ -0,0 +1,158 @@ +--- +title: "SVG Rendering: Chromium vs Servo vs resvg" +tags: + - internal + - research + - chromium + - svg + - servo + - resvg +--- + +# SVG Rendering: Chromium vs Servo vs resvg + +A cross-engine comparison of how SVG rendering is factored across three +open-source engines. This is the only doc in `svg/` that steps outside +Chromium; the rest describe Chromium as-is. + +## Cross-engine factoring + +| Concern | Chromium (Blink) | Servo | resvg | +| --------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Parser | Blink HTML/XML parser; `SVGElement` subclasses | html5ever / xml5ever → `SVG*Element` DOM stubs | `roxmltree` → `usvg::Tree` (normalized) | +| Inheritance / cascade | CSS cascade on `ComputedStyle` with SVG-specific fields in `SVGComputedStyle` | Delegated to resvg (doesn't cascade SVG presentation attributes itself) | usvg resolves inheritance during parse; outputs explicit per-node values | +| `` handling | Runtime shadow-DOM instantiation; invalidation on target change | Delegated | Deep copy at parse time; independent subtree | +| Text | Two-phase: LayoutNG inline + `SvgTextLayoutAlgorithm` | Delegated | Shaped (rustybuzz) + flattened to `Path` nodes at parse time | +| Layout | `LayoutSVG*` tree; local transforms + bounding boxes | Treats `` as a replaced element; no SVG-specific layout | No layout — usvg emits pre-resolved absolute transforms and bboxes | +| Paint backend | Skia via `cc::PaintRecord` / `cc::PaintFlags` | Via resvg → tiny-skia → WebRender image | tiny-skia (CPU) `Pixmap` | +| Paint servers | `LayoutSVGResource*` with per-client shader cache | Delegated | Top-level pools in `Tree`; rendered per-use | +| Filters | `SVGFilterBuilder` → `FilterEffect` → Skia `PaintFilter`; may be compositor-accel. | Delegated | Sequential primitive pipeline on CPU | +| Clip / mask | Path-based (fast) + rasterized fallback | Delegated | Pixel buffers; apply via tiny-skia `Mask` | +| Composite / GPU | cc property trees + render surfaces; GPU raster | WebRender rasterizes the resvg-produced image | None — CPU only | +| `` | Full HTML paint bridge | Unknown / not supported in practice | Not supported (by design) | +| Animation | CSS + SMIL | Delegated | Static (by design) | + +### Short version + +- **Chromium** implements SVG as a first-class citizen of Blink's pipeline, + deeply integrated with the compositor, CSS cascade, and GPU raster. It + pays the integration cost but supports the full spec and animates. +- **Servo** treats SVG as a black box: parse to a DOM just enough for + scripting, but all rendering decisions are delegated to resvg. Inline + SVG is serialized to a `data:` URL and shipped through the image cache + as a rasterized bitmap. No SVG pipeline of its own. +- **resvg** is two crates: **usvg** (normalize everything — inheritance, + ``, units, text shaping) and **resvg** (draw a normalized tree with + tiny-skia). Static-only, CPU-only, but comprehensive for the + features it supports. + +## The usvg/resvg split as a design pattern + +resvg's README explicitly calls out the split: + +> SVG parsing and rendering are two completely separate steps… split into two +> separate libraries: `resvg` and `usvg`. Meaning you can easily write your +> own renderer on top of `usvg` using any 2D library of your liking. + +What usvg normalizes away, so the renderer doesn't have to: + +- **Inheritance**: every node in the output tree has every presentation + attribute resolved. No `currentColor` resolution at paint time; no + "inherit this from my parent." +- **`` expansion**: the target is deep-cloned into the use site. +- **Unit resolution**: em, %, mm, in — all become user units. +- **`objectBoundingBox` gradients**: converted to `userSpaceOnUse`. +- **Basic shape conversion**: ``, ``, ``, ``, + ``, `` all become `` equivalents. +- **Arcs**: arc-to-cubic decomposition. +- **``**: resolved at parse time. +- **Text**: shaped with rustybuzz, decomposed to `Path` nodes (and + `Image` nodes for color emoji). +- **Bounding boxes and absolute transforms**: pre-computed per node. +- **Paint servers**: pulled into `Tree`-level pools (gradients, + patterns, clips, masks, filters) and referenced by `Arc`. + +The pre-computed bounding boxes are especially important: `Group::abs_bbox` +lets the renderer skip subtrees that don't intersect the dirty area +without any traversal. + +Chromium doesn't factor this way. Its `LayoutSVG*` tree is a +**mid-normalized** representation: inheritance is resolved (via +`ComputedStyle`) but `` is a runtime shadow tree and coordinate +resolution happens at paint time. The difference is that Blink needs a +live DOM that JavaScript can mutate; resvg's tree is frozen. + +## Text: two strategies + +Chromium keeps text **semantic** all the way to paint (one `DrawTextBlob` +per glyph run), which preserves selectability, accessibility, and +animation. But it requires `SvgTextLayoutAlgorithm` — a per-glyph +post-processor. + +resvg/usvg **flatten text to paths** at parse time using rustybuzz. The +renderer never sees a `Text` node (it's flattened to `Group` + +`Path` + `Image` for color emoji). Trade-offs: + +- **+** Renderer has zero font-handling code. +- **+** Reproducibility: same input → same tree on every platform. +- **+** Works on GPU without a font rasterization library. +- **−** No selection, no accessibility. +- **−** Large file sizes (outlines vs glyph ids). +- **−** Animations that depend on text content (e.g., `` of a + `` text) don't work. + +For Grida's canvas use case — render SVG as-is to Skia — the resvg +approach wins for simplicity. A pure GPU renderer that still needs +selectable text can reach for Skia's `SkTextBlob` (similar to Blink), but +needs to reproduce the SVG per-character positioning algorithm. + +## Paint servers: per-client vs per-tree + +Chromium: per-client shader cache because `objectBoundingBox` makes the +shader depend on the referencing shape's bounds. The display list (the +tile's pre-rendered commands) could in theory be shared across clients, +but Chromium currently doesn't. + +resvg: gradient/pattern definitions live at `Tree` level; the renderer +computes a fresh shader for each use site, but the underlying definition +is shared (via `Arc`). Patterns are rendered to a pixmap per use +site — reasonable on CPU; on GPU, a texture atlas or render-once-reuse- +many strategy would be worth considering. + +## Filters: DAG vs sequential + +Chromium builds an explicit DAG (`FilterEffect` graph), composes to a +single `PaintFilter`, and can translate to `CompositorFilterOperations` +for GPU execution. + +resvg walks the primitives in document order, maintaining a named result +table. No graph compilation — each primitive reads named inputs from the +table and writes to it. + +Chromium's composed `PaintFilter` reuses Skia's `SkImageFilter` graph +compiler. resvg walks primitives in document order on CPU. + +## Source anchors + +- **Chromium SVG**: this research subdirectory + (`docs/wg/research/chromium/svg/`). +- **Servo SVG stance**: + `servo/components/layout/replaced.rs` — SVG treated as replaced + element, serialized to `data:` URL; + `servo/components/net/image_cache.rs` — invokes + `resvg::render()` into a tiny-skia pixmap, shipped to WebRender as an + image; + `servo/components/script/dom/svg/` — scriptable DOM stubs without a + native rendering pipeline. +- **resvg architecture**: + `resvg/crates/usvg/src/tree/` — normalized tree types; + `resvg/crates/usvg/src/parser/` — inheritance resolution, `` + expansion, unit resolution; + `resvg/crates/usvg/src/text/flatten.rs` — rustybuzz shaping + text + outlining; + `resvg/crates/resvg/src/render.rs` — tree traversal and layer + composition; + `resvg/crates/resvg/src/path.rs` — path, gradient, pattern rendering; + `resvg/crates/resvg/src/filter/mod.rs` — primitive pipeline; + `resvg/docs/unsupported.md` — documented non-goals (no animation, no + scripting, no SVG 1.2 Tiny, no ``). diff --git a/docs/wg/research/chromium/svg/coordinate-systems.md b/docs/wg/research/chromium/svg/coordinate-systems.md new file mode 100644 index 000000000..c5afb040b --- /dev/null +++ b/docs/wg/research/chromium/svg/coordinate-systems.md @@ -0,0 +1,220 @@ +--- +title: "Chromium SVG Coordinate Systems" +tags: + - internal + - research + - chromium + - rendering + - svg +--- + +# Chromium SVG Coordinate Systems + +How Blink tracks transforms and coordinate spaces from the outer `` down +to a leaf shape. This is the conceptual difference between HTML layout +(rectangular boxes in CSS pixels) and SVG layout (user units, viewBoxes, +per-element transforms). + +## The coordinate-space hierarchy + +For a deeply nested shape, the full transform chain is: + +``` +Shape local (user units) + │ LocalSVGTransform() (the shape's own `transform` attribute) + ▼ +Parent SVG coords + │ chain of ancestor LocalToSVGParentTransform() up to nearest + ▼ +Nearest viewport coords (user units inside that ) + │ viewBoxToViewTransform (viewBox → viewport) + ▼ +Nearest viewport (CSS-px inside that ) + │ if nested, repeat for outer + ▼ +Outer CSS box + │ LocalToBorderBoxTransform() on LayoutSVGRoot + ▼ +CSS layout tree (HTML border box) + │ standard HTML transforms + ▼ +Screen +``` + +Blink exposes this via `SVGElement::LocalCoordinateSpaceTransform(CTMScope)`, +which JavaScript's `getCTM()` and `getScreenCTM()` call: + +```cpp +// third_party/blink/renderer/core/svg/svg_element.h +enum CTMScope { + kNearestViewportScope, // getCTM() — up to nearest + kScreenScope, // getScreenCTM() — all the way to the screen + kAncestorScope, // getEnclosureList() +}; +virtual AffineTransform LocalCoordinateSpaceTransform(CTMScope) const; +``` + +## `LayoutSVGRoot` — the bridge + +`LayoutSVGRoot` is the single object responsible for translating between CSS +box layout and SVG's internal coordinate space. + +```cpp +// third_party/blink/renderer/core/layout/svg/layout_svg_root.h +class LayoutSVGRoot final : public LayoutReplaced { + public: + void LayoutRoot(const PhysicalRect& content_rect); + const AffineTransform& LocalToBorderBoxTransform() const { + return local_to_border_box_transform_; + } + gfx::RectF ViewBoxRect() const; + gfx::SizeF ViewportSize() const; +}; +``` + +- **CSS side:** `LayoutSVGRoot` extends `LayoutReplaced`, so CSS treats it + like an `` — it has `width`/`height`/`aspect-ratio`, participates in + flex/grid/block layout, and has a border box. +- **SVG side:** inside its border box, the `` establishes an SVG + viewport in user units. `viewBox` and `preserveAspectRatio` map that + viewport to the border box. + +`LocalToBorderBoxTransform()` is the pre-composed transform that encodes: + +1. Translation to the SVG viewport origin within the border box (CSS + padding + border offset). +2. Scaling + translation from `viewBox` to viewport size, accounting for + `preserveAspectRatio` alignment (`xMidYMid meet`, `xMinYMin slice`, …). +3. CSS `zoom` and device-pixel scale where applicable. + +## `viewBox` and `preserveAspectRatio` + +The implementation is in `SVGFitToViewBox`: + +```cpp +// third_party/blink/renderer/core/svg/svg_fit_to_view_box.h +static AffineTransform ViewBoxToViewTransform( + const gfx::RectF& view_box, + const SVGPreserveAspectRatio&, + const gfx::SizeF& viewport_size); +``` + +This function is called in at least three places: + +- `LayoutSVGRoot` — outer `` to border box. +- `LayoutSVGViewportContainer` — nested `` inside another. +- `LayoutSVGResourcePattern::BuildPatternData()` — `` with a + `viewBox` (see [paint-servers.md](./paint-servers.md)). +- `LayoutSVGResourceMarker` — `` with a `viewBox`. + +`preserveAspectRatio` values: `none`, `xMin/xMid/xMax × YMin/YMid/YMax`, each +paired with `meet` (fit fully, letterbox) or `slice` (fill fully, crop). + +Default: `xMidYMid meet`. + +## `LocalToSVGParentTransform()` + +Every `LayoutSVGModelObject` carries an `AffineTransform` mapping its own +coordinate space to its parent's. Sources that contribute: + +1. The element's `transform` attribute. +2. CSS `transform` (SVG 2 merged these with CSS). +3. `x` / `y` attributes on elements like ``, ``, `` + (treated as a translate). +4. `viewBox` → viewport for nested `` (handled inside the viewport + container's transform). +5. `animateMotion` offset (SMIL motion path). +6. Non-scaling-stroke correction (not the element's own transform, but a + stroke-time un-scale; see [path-geometry.md](./path-geometry.md)). + +```cpp +// LayoutSVGModelObject exposes: +virtual AffineTransform LocalSVGTransform() const; // this element's transform +virtual AffineTransform LocalToSVGParentTransform() const; // composed +``` + +The painter calls `LocalSVGTransform()` before recording child paint ops +(via `ScopedSVGTransformState`), so the composed CTM naturally accumulates +down the tree without requiring a separate property tree traversal. + +## Percentage length resolution + +SVG percentages resolve differently from CSS. A `` resolves +against the **nearest viewport-establishing ancestor** — the nearest +`` or ``, not the immediate parent. This is handled by +`SVGLengthContext`: + +```cpp +// third_party/blink/renderer/core/svg/svg_length_context.h +class SVGLengthContext { + public: + explicit SVGLengthContext(const SVGElement* context); + float ValueForLength(const Length&, SVGLengthMode) const; + // … +}; +``` + +`SVGLengthMode` is one of `kWidth`, `kHeight`, `kOther` (diagonal = +sqrt(w² + h²) / sqrt(2)) — the spec requires different resolution axes for +different attributes. + +## Pattern & gradient units + +`patternUnits`, `patternContentUnits`, `gradientUnits` use one of: + +- `userSpaceOnUse` — coordinates are in the user space of the element + **referencing** the paint server. +- `objectBoundingBox` — coordinates are fractions of the referencing + element's bounding box (so `x="0"` to `x="1"` spans the whole shape). + +This is resolved in `LayoutSVGResourcePattern::BuildPatternData()` and the +equivalent for gradients, producing an `AffineTransform` that is composed +into the shader's local matrix. + +## Non-scaling stroke + +`vector-effect: non-scaling-stroke` decouples stroke width from the element's +transform. Implementation: + +```cpp +// third_party/blink/renderer/core/layout/svg/layout_svg_shape.cc +if (HasNonScalingStroke()) { + root_transform.Scale(StyleRef().EffectiveZoom()) + .PreConcat(NonScalingStrokeTransform()); + path = &NonScalingStrokePath(); +} +``` + +The path is pre-transformed into a coordinate space where scale has been +factored out, then stroked at the nominal width, then projected back. The +result is a stroke whose visual width is independent of the element's +transform. + +## Hit-test coordinate mapping + +Hit tests start in CSS-pixel screen space and walk down: + +1. `LayoutSVGRoot::NodeAtPoint()` applies the inverse of + `LocalToBorderBoxTransform()` to get SVG viewport coords. +2. `LayoutSVGContainer::NodeAtPoint()` iterates children in paint order, + recursively applying each child's `LocalSVGTransform().Inverse()`. +3. `LayoutSVGShape::NodeAtPoint()` tests the transformed location against + the cached `Path` (fill via winding rule, stroke via the cached + `stroke_path_cache_`). + +`pointer-events` gates whether fill/stroke count: +`auto | none | visiblePainted | visibleFill | visibleStroke | visible | +painted | fill | stroke | all`. + +## Source files + +| File | Role | +| --------------------------------------------------------------------------------- | ------------------------------------------------ | +| `third_party/blink/renderer/core/svg/svg_element.h` | `LocalCoordinateSpaceTransform`, CTMScope | +| `third_party/blink/renderer/core/svg/svg_fit_to_view_box.h` | `ViewBoxToViewTransform()` | +| `third_party/blink/renderer/core/svg/svg_preserve_aspect_ratio.h` | Alignment / meet-or-slice enum | +| `third_party/blink/renderer/core/svg/svg_length_context.h` | Percentage and unit resolution | +| `third_party/blink/renderer/core/layout/svg/layout_svg_root.h` | Outer ``; `LocalToBorderBoxTransform` | +| `third_party/blink/renderer/core/layout/svg/layout_svg_viewport_container.h` | Nested `` | +| `third_party/blink/renderer/core/layout/svg/layout_svg_transformable_container.h` | `` | +| `third_party/blink/renderer/core/layout/svg/layout_svg_model_object.h` | `LocalSVGTransform`, `LocalToSVGParentTransform` | diff --git a/docs/wg/research/chromium/svg/index.md b/docs/wg/research/chromium/svg/index.md new file mode 100644 index 000000000..509a8bb5e --- /dev/null +++ b/docs/wg/research/chromium/svg/index.md @@ -0,0 +1,63 @@ +--- +title: "Chromium SVG Rendering Research" +tags: + - internal + - research + - chromium + - rendering + - svg +--- + +# Chromium SVG Rendering Research + +How Blink (Chromium's rendering engine) renders SVG. These documents describe +the end-to-end pipeline — DOM construction, layout, paint, and compositing — +as it applies to SVG content, both inline and as a standalone image format. + +SVG is part of HTML. An `` element can be the root of a document, embedded +inline inside an HTML page, or loaded as an image. Blink handles all three +cases by wiring SVG into the same Style → Layout → Paint → Composite pipeline +that drives HTML, with an additional "SVG local coordinate space" layer that +lives under `LayoutSVGRoot`. + +## Documents + +| Document | Scope | +| -------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [pipeline.md](./pipeline.md) | End-to-end pipeline: DOM → LayoutSVG\* → paint → composite | +| [coordinate-systems.md](./coordinate-systems.md) | viewBox, preserveAspectRatio, CTM, local-to-parent transforms | +| [paint-servers.md](./paint-servers.md) | Gradients and patterns as shader-producing resources | +| [resources-and-effects.md](./resources-and-effects.md) | ``, ``, ``, `` resolution | +| [path-geometry.md](./path-geometry.md) | `d=` parsing, `SVGPath` → `SkPath`, stroke properties → Skia | +| [text.md](./text.md) | SVG text: two-phase layout, text-on-path, `SvgTextLayoutAlgorithm` | +| [use-and-foreign-object.md](./use-and-foreign-object.md) | `` shadow instance tree, `` HTML-in-SVG bridging | +| [svg-as-image.md](./svg-as-image.md) | Inline vs standalone vs ``-embedded SVG; `SVGImage`, `SVGImageForContainer` | +| [comparison.md](./comparison.md) | Cross-engine comparison: Chromium vs Servo vs resvg | + +## Pre-existing companion docs + +These sit under `docs/wg/research/chromium/` (not this subdirectory): + +- [`svg-pattern.md`](../svg-pattern.md) — deep dive on the `` paint + server (pre-dates this folder). [paint-servers.md](./paint-servers.md) + summarizes and cross-references it. +- [`render-surfaces.md`](../render-surfaces.md) — compositor render-surface + creation rules (filters, masks, blend modes). Referenced by + [resources-and-effects.md](./resources-and-effects.md). +- [`paint-recording.md`](../paint-recording.md) — `PaintRecord`, display + lists, R-tree indexing. SVG painters emit into this same machinery. +- [`blink-rendering-pipeline.md`](../blink-rendering-pipeline.md) — the + Style → Layout → Paint pipeline for HTML. SVG layers on top of it. + +## Source locations + +All findings are from the `third_party/blink/renderer/core/` subtree: + +- `svg/` — DOM element classes (`SVGElement`, `SVGPathElement`, …). +- `layout/svg/` — layout tree (`LayoutSVGRoot`, `LayoutSVGShape`, …). +- `paint/` — painters (`SVGShapePainter`, `SVGObjectPainter`, …). +- `svg/graphics/filters/` — filter graph builder. +- `svg/graphics/` — `SVGImage`, `SVGImageForContainer`. + +Platform-graphics types (`Pattern`, `Gradient`, `Path`, `GraphicsContext`) live +under `third_party/blink/renderer/platform/graphics/`. diff --git a/docs/wg/research/chromium/svg/paint-servers.md b/docs/wg/research/chromium/svg/paint-servers.md new file mode 100644 index 000000000..d323ae41c --- /dev/null +++ b/docs/wg/research/chromium/svg/paint-servers.md @@ -0,0 +1,210 @@ +--- +title: "Chromium SVG Paint Servers" +tags: + - internal + - research + - chromium + - rendering + - svg +--- + +# Chromium SVG Paint Servers + +How `fill="url(#id)"` and `stroke="url(#id)"` resolve into Skia shaders. Paint +servers are the ``, ``, and `` +elements. They are **resources** — they never create compositor layers or +render surfaces; they produce shaders applied to draw calls at paint time. + +A deep-dive on `` specifically lives at +[svg-pattern.md](../svg-pattern.md). This document covers the shared paint +server architecture and gradients. + +## Class hierarchy + +``` +LayoutSVGHiddenContainer never visual; extends LayoutSVGContainer + └── LayoutSVGResourceContainer base for all -type resources + ├── LayoutSVGResourcePaintServer ApplyShader() → cc::PaintFlags + │ ├── LayoutSVGResourcePattern + │ └── LayoutSVGResourceGradient + │ ├── LayoutSVGResourceLinearGradient + │ └── LayoutSVGResourceRadialGradient + ├── LayoutSVGResourceClipper (see resources-and-effects.md) + ├── LayoutSVGResourceMasker + ├── LayoutSVGResourceFilter + └── LayoutSVGResourceMarker +``` + +All paint servers share the same entry point: + +```cpp +// third_party/blink/renderer/core/layout/svg/layout_svg_resource_paint_server.h +class LayoutSVGResourcePaintServer : public LayoutSVGResourceContainer { + public: + virtual bool ApplyShader(const SVGResourceClient&, + const gfx::RectF& reference_box, + const AffineTransform* additional_transform, + const AutoDarkMode&, + cc::PaintFlags&) = 0; +}; +``` + +## Resolution pipeline — from `fill="url(#id)"` to shader + +1. **Style cascade** — `fill: url(#id)` becomes an `SVGPaintType::kUriFunction` + in `SVGComputedStyle::fill.Resource()`. +2. **Client registration** — when a `LayoutSVG*` object is created for a shape + that references a resource, `SVGResources::UpdatePaints()` registers the + shape as a client of the target element via `SVGElementResourceClient`. +3. **Paint time** — `SVGShapePainter` calls `SVGObjectPainter::PreparePaint()`, + which looks up the resource and calls `ApplyShader()`: + +```cpp +// third_party/blink/renderer/core/paint/svg_object_painter.cc +bool ApplyPaintResource(const SvgContextPaints::ContextPaint& context_paint, + const AffineTransform* additional_paint_server_transform, + cc::PaintFlags& flags) { + SVGElementResourceClient* client = + SVGResources::GetClient(context_paint.object); + auto* uri_resource = GetSVGResourceAsType( + *client, context_paint.paint.Resource()); + if (!uri_resource->ApplyShader( + *client, SVGResources::ReferenceBoxForEffects(context_paint.object), + additional_paint_server_transform, auto_dark_mode, flags)) + return false; + return true; +} +``` + +4. **Shader attached** — the paint server sets `cc::PaintShader` on the + `cc::PaintFlags`, which is then used by the subsequent `DrawPath` / + `DrawRect`. + +`SVGResources::ReferenceBoxForEffects()` returns the bounding box to use +for `objectBoundingBox` resolution; this is configurable via +`geometry_box` (`fill-box` / `stroke-box` / `view-box`) from the +`geometry-box` CSS value. + +## Per-client caching + +Paint servers maintain a per-client cache because `objectBoundingBox` makes +tile/gradient geometry depend on the referencing shape's bounds: + +```cpp +// third_party/blink/renderer/core/layout/svg/layout_svg_resource_pattern.h +// A pattern can be referenced by many shapes; each shape may have a +// different bounding box, so each client needs its own Pattern shader. +HeapHashMap, std::unique_ptr> + pattern_map_; +``` + +Gradients follow the same pattern. + +Invalidation: when the resource element changes, `SVGResource` notifies its +clients via `SVGResourceClient::ResourceContentChanged()`, which clears the +cache and schedules paint invalidation on each shape. + +## Gradients + +### Attribute collection + +Gradients inherit attributes through an `xlink:href` chain (same as patterns). +`SVGGradientElement::CollectCommonAttributes()` walks the href chain with +cycle detection, filling in any unset attributes from referenced elements. + +Common gradient attributes: + +- `gradientUnits` — `userSpaceOnUse` or `objectBoundingBox` +- `gradientTransform` — applied as shader local matrix +- `spreadMethod` — `pad` / `reflect` / `repeat` (Skia `SkTileMode`) +- color stops (from `` children): offset, color, opacity + +Linear-specific: `x1`, `y1`, `x2`, `y2` (the gradient vector). +Radial-specific: `cx`, `cy`, `r`, `fx`, `fy`, `fr` (center, radius, focal). + +### BuildGradientData + +```cpp +// third_party/blink/renderer/core/layout/svg/layout_svg_resource_gradient.cc +std::unique_ptr LayoutSVGResourceGradient::BuildGradientData( + const gfx::RectF& object_bounding_box) { + const GradientAttributes& attributes = EnsureAttributes(); + auto gradient_data = std::make_unique(); + + // 1. Resolve endpoints in user space: + // objectBoundingBox → scale by object bbox, then translate + // userSpaceOnUse → already in user space + + // 2. Build Gradient with resolved stops + spread mode + + // 3. Apply gradientTransform to Gradient's local matrix + gradient_data->gradient->SetGradientSpaceTransform(gradient_transform); + return gradient_data; +} +``` + +The resolved `Gradient` (platform wrapper around `SkShader`) produces a +`cc::PaintShader` via `Gradient::CreateShader()`, which is attached to +`cc::PaintFlags` identically to the pattern case. + +## Patterns + +See [svg-pattern.md](../svg-pattern.md) for the full walkthrough. In short: + +1. `CollectPatternAttributes()` — walk href chain, fill defaults. +2. `BuildPatternData()`: + - Resolve `tile_bounds` (x/y/width/height) in user space. + - Resolve `tile_transform` from `viewBox` or `patternContentUnits`. + - Call `AsPaintRecord(tile_transform)` — record tile children into a + `PaintRecord` (**not** a bitmap). + - Wrap recording in `PaintRecordPattern`, create `PaintShader` with + `SkTileMode::kRepeat` on both axes. + - Compose shader local matrix: + `Translate(tile.x, tile.y) · patternTransform`. +3. `ApplyShader()` caches per client, applies shader to `cc::PaintFlags`. + +Key insight: the tile is a display list, not a rasterized bitmap. Skia +rasterizes it lazily at the correct scale, so patterns stay +resolution-independent. + +## Shared abstractions + +``` +platform/graphics/ +├── Pattern abstract; holds cached PaintShader +│ ├── ImagePattern raster-image patterns (CSS background-image) +│ └── PaintRecordPattern SVG — record-based tiling +└── Gradient abstract; holds stops + spread + local matrix + ├── LinearGradient + ├── RadialGradient + └── ConicGradient CSS conic-gradient() only (no SVG equivalent) +``` + +Both `Pattern` and `Gradient` expose `ApplyToFlags(cc::PaintFlags&, +SkMatrix& local_matrix)`, which constructs the `cc::PaintShader` and attaches +it. The shader is cached so long as `local_matrix` doesn't change. + +## What paint servers never do + +- They never create compositor layers. +- They never create render surfaces (see [render-surfaces.md](../render-surfaces.md)). +- They never trigger compositing promotion. +- They never participate in damage tracking as visual elements — they're + pure shader sources. + +Any SVG feature that would need compositing (blend mode, filter, mask on a +**consuming** shape) is handled by that consuming shape, not by the paint +server. + +## Source files + +| File | Role | +| ----------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `third_party/blink/renderer/core/layout/svg/layout_svg_resource_paint_server.h` | Abstract base | +| `third_party/blink/renderer/core/layout/svg/layout_svg_resource_gradient.cc` | Gradient base; `BuildGradientData()`, `ApplyShader()` | +| `third_party/blink/renderer/core/layout/svg/layout_svg_resource_linear_gradient.cc` | Linear-specific | +| `third_party/blink/renderer/core/layout/svg/layout_svg_resource_radial_gradient.cc` | Radial-specific | +| `third_party/blink/renderer/core/layout/svg/layout_svg_resource_pattern.cc` | See [svg-pattern.md](../svg-pattern.md) | +| `third_party/blink/renderer/core/paint/svg_object_painter.cc` | Resource resolution at paint time | +| `third_party/blink/renderer/platform/graphics/gradient.cc` | Platform gradient wrapper | +| `third_party/blink/renderer/platform/graphics/pattern.cc` | Platform pattern wrapper | diff --git a/docs/wg/research/chromium/svg/path-geometry.md b/docs/wg/research/chromium/svg/path-geometry.md new file mode 100644 index 000000000..6e37e2e02 --- /dev/null +++ b/docs/wg/research/chromium/svg/path-geometry.md @@ -0,0 +1,251 @@ +--- +title: "Chromium SVG Path Geometry and Stroking" +tags: + - internal + - research + - chromium + - rendering + - svg +--- + +# Chromium SVG Path Geometry and Stroking + +How `` data becomes an `SkPath`, and how SVG stroke properties +(`stroke-width`, `stroke-linecap`, `stroke-dasharray`, …) are mapped to Skia. + +## Path data parsing + +### Byte stream representation + +`SVGPath` stores parsed path data in an `SVGPathByteStream` — a compact +binary format that represents each segment as a command byte plus its +float parameters. This is the canonical internal representation; the +ASCII `d="M 10 10 L 50 50"` string is parsed once and cached. + +```cpp +// third_party/blink/renderer/core/svg/svg_path.h +class SVGPath final : public SVGPropertyBase { + public: + const SVGPathByteStream& ByteStream() const; + SVGPath* Clone() const; + String ValueAsString() const override; + SVGParsingError SetValueAsString(const String&); +}; +``` + +### Parser — consumer pattern + +The parser is a template-based producer/consumer: + +```cpp +// third_party/blink/renderer/core/svg/svg_path_parser.h +namespace svg_path_parser { + template + inline bool ParsePath(SourceType& source, ConsumerType& consumer) { + while (source.HasMoreData()) { + PathSegmentData segment = source.ParseSegment(); + if (segment.command == kPathSegUnknown) return false; + consumer.EmitSegment(segment); + } + return true; + } +} +``` + +Sources: `SVGPathStringSource` (ASCII `d=`), `SVGPathByteStreamSource` (compact +binary). + +Consumers: + +- `SVGPathByteStreamBuilder` — writes into a new byte stream. +- `SVGPathNormalizer` — converts relative commands to absolute; keeps arcs. +- `SVGPathStringBuilder` — serializes back to ASCII. +- `SVGPathBuilder` — **the renderer consumer**: builds a `Path` (SkPath + wrapper) by emitting `moveTo`, `lineTo`, `cubicTo`, etc. +- `SVGMarkerDataBuilder` — walks to compute marker positions (see + [resources-and-effects.md](./resources-and-effects.md)). +- `SVGPathAbsolutizer` — variant of normalizer. + +Arcs (`A`/`a` command) are typically converted to cubic Béziers at build +time via the standard endpoint-to-center-parameterization + arc-to-cubic +decomposition. + +## `Path` — the Skia wrapper + +`Path` (`third_party/blink/renderer/platform/graphics/path.h`) is Blink's +wrapper around `SkPath`. It adds helpers that SVG needs: + +- `BoundingRect()`, `StrokeBoundingRect(const StrokeData&)` +- `Contains(const gfx::PointF&, WindRule)` — point-in-path hit testing +- `StrokeContains(const gfx::PointF&, const StrokeData&)` +- `ApplyTransform(const AffineTransform&)` + +Shapes build their `Path` lazily via `SVGGeometryElement::AsPath()`, which +dispatches to element-specific construction: + +```cpp +// third_party/blink/renderer/core/layout/svg/layout_svg_shape.cc +void LayoutSVGShape::CreatePath() { + if (!path_) + path_ = std::make_unique(); + *path_ = To(GetElement())->AsPath(); + DCHECK(!stroke_path_cache_); +} +``` + +``, ``, ``, ``, ``, `` each +build their own `Path` directly (e.g., `SVGRectElement::AsPath()` constructs +a rectangle, or a rounded rect if `rx`/`ry` are set). `` replays the +byte stream through `SVGPathBuilder`. + +## Fill rule and winding + +`fill-rule: nonzero | evenodd` (and `clip-rule` for clip paths) maps +directly to Skia's `SkPathFillType::kWinding` / `kEvenOdd`. The fill rule is +not stored on the `SkPath` itself when it's used for stroking — it's only +applied at fill time. + +## Stroke + +### Stroke properties mapping + +```cpp +// third_party/blink/renderer/core/layout/svg/svg_layout_support.h +static void ApplyStrokeStyleToStrokeData(StrokeData&, + const ComputedStyle&, + const LayoutObject&, + float dash_scale_factor); +``` + +`StrokeData` (`platform/graphics/stroke_data.h`) maps as follows: + +| SVG / CSS property | `StrokeData` field | Skia / SkPaint equivalent | +| ------------------- | ------------------------ | --------------------------------------- | +| `stroke-width` | `thickness_` | `SkPaint::setStrokeWidth` | +| `stroke-linecap` | `line_cap_` | `SkPaint::Cap` — Butt / Round / Square | +| `stroke-linejoin` | `line_join_` | `SkPaint::Join` — Miter / Round / Bevel | +| `stroke-miterlimit` | `miter_limit_` | `SkPaint::setStrokeMiter` | +| `stroke-dasharray` | `dash_` | `SkDashPathEffect::Make(intervals, …)` | +| `stroke-dashoffset` | phase arg of dash effect | same | + +### Dash scaling for transforms + +```cpp +// layout_svg_shape.cc +StrokeData stroke_data; +SVGLayoutSupport::ApplyStrokeStyleToStrokeData(stroke_data, StyleRef(), *this, + DashScaleFactor()); +``` + +`DashScaleFactor()` accounts for uniform scale components of the element's +transform so that `stroke-dasharray` intervals remain visually consistent +when the shape is scaled. For non-uniform scales, the approximation can +diverge from the spec. + +### Non-scaling stroke + +`vector-effect: non-scaling-stroke` un-scales the path before stroking. See +[coordinate-systems.md](./coordinate-systems.md#non-scaling-stroke). + +### Stroke-path cache + +```cpp +// layout_svg_shape.h +mutable std::unique_ptr stroke_path_cache_; +``` + +The actual stroked `Path` (result of applying stroke width to the geometry) +is cached for hit testing — computing a stroke outline is expensive, so +hit tests reuse it across pointer events until the geometry, transform, or +stroke properties change. + +### Stroke bounds + +```cpp +// layout_svg_shape.cc +gfx::RectF LayoutSVGShape::StrokeBoundingBox() const { + if (!StyleRef().HasStroke() || IsShapeEmpty()) + return fill_bounding_box_; + if (!HasPath()) { + DCHECK(CanUseSimpleStrokeApproximation(geometry_type_)); + return ApproximateStrokeBoundingBox(fill_bounding_box_); + } + StrokeData stroke_data; + SVGLayoutSupport::ApplyStrokeStyleToStrokeData(stroke_data, StyleRef(), + *this, DashScaleFactor()); + DashArray dashes; + stroke_data.SetLineDash(dashes, 0); // dashes don't affect bounds per spec + const gfx::RectF stroke_bounds = GetPath().StrokeBoundingRect(stroke_data); + return gfx::UnionRects(fill_bounding_box_, stroke_bounds); +} +``` + +Two fast paths: + +- Empty shape → fill bbox only (no stroke). +- Simple geometry (rect, circle, ellipse, line) where bounds can be computed + analytically → `ApproximateStrokeBoundingBox()` inflates fill bbox by + ~`stroke-width / 2 * miter_factor`. + +Otherwise, Skia computes stroke bounds from the actual stroke outline. + +Dashes are explicitly cleared before computing bounds — the SVG spec says +bounds should reflect the un-dashed stroke envelope, not the gap regions. + +## Shape rendering modes + +`shape-rendering: auto | optimizeSpeed | crispEdges | geometricPrecision` +maps to Skia anti-aliasing and hinting: + +- `crispEdges` — disable antialiasing (`SkPaint::setAntiAlias(false)`). +- `optimizeSpeed` — implementation-defined; Blink treats as `crispEdges` + when beneficial. +- `geometricPrecision`, `auto` — full anti-aliasing. + +## Hit testing fill and stroke + +```cpp +// layout_svg_shape.cc +bool LayoutSVGShape::ShapeDependentFillContains( + const HitTestLocation& location, + const WindRule fill_rule) const { + return location.Intersects(GetPath(), fill_rule); +} + +bool LayoutSVGShape::ShapeDependentStrokeContains( + const HitTestLocation& location) { + if (!stroke_path_cache_) { + const Path* path = path_.get(); + AffineTransform root_transform; + if (HasNonScalingStroke()) { + root_transform.Scale(StyleRef().EffectiveZoom()) + .PreConcat(NonScalingStrokeTransform()); + path = &NonScalingStrokePath(); + } + StrokeData stroke_data; + SVGLayoutSupport::ApplyStrokeStyleToStrokeData( + stroke_data, StyleRef(), *this, DashScaleFactor()); + stroke_path_cache_ = std::make_unique( + path->StrokePath(stroke_data, root_transform)); + } + return stroke_path_cache_->Contains(location.TransformedPoint()); +} +``` + +Hit testing follows `pointer-events` to decide whether to test fill, stroke, +or both. + +## Source files + +| File | Role | +| -------------------------------------- | --------------------------------------------- | +| `core/svg/svg_path.h` | Parsed path wrapper; byte stream | +| `core/svg/svg_path_parser.h` | Template parser; consumer pattern | +| `core/svg/svg_path_byte_stream.h` | Compact binary representation | +| `core/svg/svg_path_builder.h` | Byte stream → `Path` (SkPath) construction | +| `core/svg/svg_path_string_source.h` | ASCII `d=` tokenizer | +| `core/svg/svg_path_normalizer.h` | Relative → absolute, arc-to-cubic | +| `core/layout/svg/layout_svg_shape.h` | Shape base; owns `Path`, `stroke_path_cache_` | +| `core/layout/svg/svg_layout_support.h` | `ApplyStrokeStyleToStrokeData()` | +| `platform/graphics/path.h` | Blink `Path` wrapper around `SkPath` | +| `platform/graphics/stroke_data.h` | Stroke properties value object | diff --git a/docs/wg/research/chromium/svg/pipeline.md b/docs/wg/research/chromium/svg/pipeline.md new file mode 100644 index 000000000..1117afc90 --- /dev/null +++ b/docs/wg/research/chromium/svg/pipeline.md @@ -0,0 +1,226 @@ +--- +title: "Chromium SVG Pipeline Overview" +tags: + - internal + - research + - chromium + - rendering + - svg +--- + +# Chromium SVG Pipeline Overview + +The end-to-end pipeline for rendering SVG inside Blink. Emphasis on where the +SVG pipeline diverges from HTML. + +``` +Parsing Style Layout Paint Composite +─────── ───── ────── ───── ───────── + ──▶ CSS cascade ──▶ LayoutSVGRoot ──▶ SVGRootPainter ──▶ cc::PaintRecord + │ on SVGElements │ │ │ + │ + presentation ├── LayoutSVG ├── SVGContainer ├── property trees + │ attributes ├── Container ├── Painter │ (transform/ + │ aliased to CSS ├── LayoutSVGShape ├── SVGShape │ effect/clip) + │ ├── LayoutSVGText ├── Painter │ + │ └── … └── … └── RenderSurfaces + ▼ ▲ ▲ for filter/mask +SVGElement tree │ │ + SVG resources resolved paint servers resolved + (clipPath, mask, filter, via SVGResources::GetClient + marker, pattern, gradient) → shader on PaintFlags +``` + +## Phases + +### 1. Parsing → `SVGElement` tree + +Blink's XML/HTML parser constructs a DOM where `` and its descendants +become concrete `SVGElement` subclasses: + +- Structural: `SVGSVGElement`, `SVGGElement`, `SVGDefsElement`, `SVGSymbolElement` +- Shapes: `SVGPathElement`, `SVGRectElement`, `SVGCircleElement`, `SVGEllipseElement`, `SVGLineElement`, `SVGPolygonElement`, `SVGPolylineElement` +- Text: `SVGTextElement`, `SVGTSpanElement`, `SVGTextPathElement` +- Paint servers: `SVGLinearGradientElement`, `SVGRadialGradientElement`, `SVGPatternElement` +- Effects: `SVGClipPathElement`, `SVGMaskElement`, `SVGFilterElement`, `SVGMarkerElement` +- Filter primitives: `SVGFEGaussianBlurElement`, `SVGFEColorMatrixElement`, 20+ more +- Structural refs: `SVGUseElement`, `SVGImageElement`, `SVGForeignObjectElement` + +Presentation attributes (`fill="red"`) and typed attributes (`width="100"`) are +stored as `SVGAnimated*` wrappers around a base value and an animated value — +`SVGAnimatedLength`, `SVGAnimatedNumber`, `SVGAnimatedTransformList`, etc. + +```cpp +// third_party/blink/renderer/core/svg/svg_animated_length.h +class SVGAnimatedLength : public ScriptWrappable, + public SVGAnimatedProperty { + public: + SVGAnimatedLength(SVGElement* context_element, + const QualifiedName& attribute_name, + SVGLengthMode mode, + SVGLength::Initial initial_value, + CSSPropertyID css_property_id = CSSPropertyID::kInvalid); + const CSSValue* CssValue() const final; // bridges SVG value → CSS cascade +}; +``` + +The `css_property_id` argument is the bridge: SVG presentation attributes that +have a CSS counterpart (`fill`, `stroke`, `opacity`, `font-family`, …) are fed +into the CSS cascade as if they were inline styles. The cascade also accepts +real CSS rules (`rect { fill: red; }`). + +### 2. Style → `ComputedStyle` + +SVG reuses the HTML style system. `ComputedStyle` is the same type for HTML and +SVG; SVG-specific properties live in `SVGComputedStyle` +(`third_party/blink/renderer/core/style/svg_computed_style.h`), accessed via +`style.SvgStyle()`. Fields include: + +- `fill`, `stroke`, `stroke_dasharray`, `fill_rule`, `clip_rule` +- `marker_start`, `marker_mid`, `marker_end` +- `stop_color`, `stop_opacity`, `flood_color`, `flood_opacity` +- `paint_order` (for controlling fill/stroke/marker order) + +The style cascade runs the same way as for HTML. The only SVG-specific step is +that presentation attributes are parsed as CSS values before entering the +cascade. + +### 3. Layout → `LayoutSVG*` tree + +SVG has its own layout tree that grafts into Blink's layout tree at +`LayoutSVGRoot`. `LayoutSVGRoot` extends `LayoutReplaced` (a CSS-sized replaced +element), and everything below it is SVG-native. + +``` +LayoutObject +├── LayoutSVGRoot extends LayoutReplaced; CSS box ↔ SVG viewport +│ └── (SVG subtree below) +├── LayoutSVGModelObject abstract base for SVG content +│ ├── LayoutSVGContainer , — groups children +│ │ ├── LayoutSVGTransformableContainer +│ │ ├── LayoutSVGViewportContainer nested +│ │ ├── LayoutSVGHiddenContainer , , , , +│ │ │ └── LayoutSVGResourceContainer base for all resource containers +│ │ │ ├── LayoutSVGResourcePaintServer abstract +│ │ │ │ ├── LayoutSVGResourcePattern +│ │ │ │ └── LayoutSVGResourceGradient (linear + radial concrete) +│ │ │ ├── LayoutSVGResourceClipper +│ │ │ ├── LayoutSVGResourceMasker +│ │ │ ├── LayoutSVGResourceFilter +│ │ │ └── LayoutSVGResourceMarker +│ │ └── … +│ ├── LayoutSVGShape , , , , , , +│ │ └── LayoutSVGPath specialization where path can be complex +│ ├── LayoutSVGImage +│ └── LayoutSVGForeignObject — bridges back to HTML layout +└── LayoutSVGText — extends LayoutSVGBlock (reuses LayoutNG inline layout) + └── LayoutSVGInline , + └── LayoutSVGInlineText text runs +``` + +Unlike HTML layout (which produces rectangular fragments), SVG layout produces +**paths and bounding boxes in the element's local coordinate system**, plus a +**transform to the parent SVG coordinate system** +(`LocalToSVGParentTransform()`). The tree is then walked during paint with +these transforms composed. + +Source: `third_party/blink/renderer/core/layout/svg/layout_svg_model_object.h` +and sibling files. + +### 4. Paint → `PaintRecord` + +Each `LayoutSVG*` object has a matching painter: + +| Layout class | Painter | +| ----------------------------------- | ------------------------- | +| `LayoutSVGRoot` | `SVGRootPainter` | +| `LayoutSVGContainer` (+ subclasses) | `SVGContainerPainter` | +| `LayoutSVGShape` | `SVGShapePainter` | +| `LayoutSVGImage` | `SVGImagePainter` | +| `LayoutSVGForeignObject` | `SVGForeignObjectPainter` | +| `LayoutSVGText` | `SVGTextPainter` | + +All painters emit into the same `PaintRecord` (display list) machinery used by +HTML — see [`paint-recording.md`](../paint-recording.md). A painter: + +1. Checks paint phase (only `kForeground` for most SVG content). +2. Runs `ScopedSVGTransformState` — concats this element's + `LocalSVGTransform()` onto the `GraphicsContext` CTM. +3. Runs `ScopedSVGPaintState` — prepares fill/stroke `cc::PaintFlags`, + applying paint servers (shaders), clip paths, masks, filters. +4. Records `DrawPath` / `DrawRect` / text ops into the `PaintRecord`. + +Paint order inside a shape respects the `paint-order` CSS property; default is +`fill, stroke, markers`. + +```cpp +// third_party/blink/renderer/core/paint/svg_shape_painter.cc +void SVGShapePainter::Paint(const PaintInfo& paint_info) { + if (paint_info.phase != PaintPhase::kForeground || + layout_svg_shape_.IsShapeEmpty()) + return; + + // cull-rect skip + if (SVGModelObjectPainter::CanUseCullRect(layout_svg_shape_.StyleRef())) { + if (!paint_info.GetCullRect().IntersectsTransformed( + layout_svg_shape_.LocalSVGTransform(), + layout_svg_shape_.VisualRectInLocalSVGCoordinates())) + return; + } + + ScopedSVGTransformState transform_state(paint_info, layout_svg_shape_); + ScopedSVGPaintState paint_state(layout_svg_shape_, …); + if (!DrawingRecorder::UseCachedDrawingIfPossible(…)) { + SVGDrawingRecorder recorder(…); + PaintShape(content_paint_info); // fill → stroke → markers + } +} +``` + +### 5. Composite → cc property trees + +Once `PaintRecord`s exist, Blink hands them to the compositor (`cc/`). SVG +content participates in the same property trees (transform / effect / clip / +scroll) as HTML. SVG-specific wrinkles: + +- Most SVG transforms are **baked into the `PaintRecord`** via + `canvas->concat(local_transform)` rather than as a compositor transform + node. Compositor transforms are reserved for elements that opt into + compositing (e.g., an animated `` root, or an ancestor with + `will-change: transform`). +- ``, ``, and explicit blend modes can force a **render + surface** (offscreen buffer). See + [render-surfaces.md](../render-surfaces.md). +- Paint servers (pattern, gradient) **never** create compositor layers — + they are always resolved as shaders at paint time. + See [svg-pattern.md](../svg-pattern.md) for the pattern case. + +## How SVG diverges from HTML + +| Aspect | HTML | SVG | +| ------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Layout tree root | `LayoutView` | `LayoutSVGRoot` (attached under HTML `LayoutView` like any replaced element) | +| Layout result | Rectangular fragments (`PhysicalFragment`) | Local paths + bounding boxes + `LocalToSVGParentTransform()` | +| Coordinate system | CSS pixels, one per element box | Arbitrary user units; `` establishes a new coordinate space; nested `` nest viewports | +| Transform ownership | Compositor transform tree | Baked into `PaintRecord` by default; compositor only for promoted layers | +| Paint order | Stacking contexts (CSS §9.9) | Document order within a group; `paint-order` controls fill/stroke/marker within a shape | +| Text layout | LayoutNG inline flow | LayoutNG inline flow **followed by** `SvgTextLayoutAlgorithm` that rewrites per-glyph positions | +| Resource resolution | URL → resource loader (for `background-image`) | `url(#id)` → same-document `SVGElementResourceClient` lookup; resolved at **paint time**, cached per-client | +| Replaced elements | ``, `