From 8ce7e9828f5ab830b87d14d9eb59754847d00431 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 12 Aug 2026 15:18:55 +0000 Subject: [PATCH 1/4] Use crates.io usvg 0.46 and anyrender_svg; drop git patches Compute SVG intrinsic dimensions in blitz-dom by re-parsing the root attributes with roxmltree/svgtypes, instead of relying on the forked usvg's Tree::intrinsic_dimensions. --- Cargo.lock | 158 +++++++++++-------------- Cargo.toml | 10 +- packages/blitz-dom/Cargo.toml | 3 +- packages/blitz-dom/src/node/element.rs | 46 ++++--- packages/blitz-dom/src/node/mod.rs | 4 +- packages/blitz-dom/src/util.rs | 41 ++++++- 6 files changed, 146 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60cdb92cc1..1d2a1ad6fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,8 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "anyrender" version = "0.12.0" -source = "git+https://github.com/DioxusLabs/anyrender?branch=devin%2F1785858394-usvg-048#dcd219746ff13a5832beab552f3f7f494d1bd84d" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea77d85ae0be665528618c97164c2b510dc635e80b805e9a9df9a9fe72da423" dependencies = [ "kurbo", "peniko", @@ -323,7 +324,7 @@ dependencies = [ "anyrender", "image", "peniko", - "read-fonts 0.39.2", + "read-fonts", "serde", "serde_json", "sha2", @@ -364,7 +365,8 @@ dependencies = [ [[package]] name = "anyrender_svg" version = "0.13.0" -source = "git+https://github.com/DioxusLabs/anyrender?branch=devin%2F1785858394-usvg-048#dcd219746ff13a5832beab552f3f7f494d1bd84d" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454619983a9b55af8692211e15d171189af9fc677646b8a4724c822fa9b6d8c5" dependencies = [ "anyrender", "image", @@ -784,12 +786,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bindgen" version = "0.72.1" @@ -900,7 +896,7 @@ dependencies = [ "percent-encoding", "rayon", "selectors", - "skrifa 0.42.1", + "skrifa", "slotmap", "smallvec", "stylo", @@ -908,6 +904,7 @@ dependencies = [ "stylo_static_prefs", "stylo_taffy", "stylo_traits", + "svgtypes", "taffy", "thin-vec", "thread_local", @@ -2870,15 +2867,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "font-types" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" -dependencies = [ - "bytemuck", -] - [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -2890,15 +2878,16 @@ dependencies = [ [[package]] name = "fontdb" -version = "0.24.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", "memmap2 0.9.11", "slotmap", "tinyvec", + "ttf-parser", ] [[package]] @@ -2915,7 +2904,7 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.2", "parlance", - "read-fonts 0.39.2", + "read-fonts", "roxmltree 0.21.1", "smallvec", "windows", @@ -3219,7 +3208,7 @@ dependencies = [ "hashbrown 0.17.1", "log", "peniko", - "skrifa 0.42.1", + "skrifa", "smallvec", "vello_common", ] @@ -3403,19 +3392,7 @@ checksum = "d12c7c642d4ce8c2e784b4751a6634bd89583912265add4a679a8882d123fbcd" dependencies = [ "bitflags 2.13.0", "bytemuck", - "read-fonts 0.39.2", - "smallvec", -] - -[[package]] -name = "harfrust" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" -dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "read-fonts 0.41.0", + "read-fonts", "smallvec", ] @@ -3885,9 +3862,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.15.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imgref" @@ -4862,7 +4839,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc8f19a1c361e5fb1a57e487b993ff8b3c44321b50ca86c9e2b235e57dc94008" dependencies = [ - "font-types 0.11.3", + "font-types", ] [[package]] @@ -5349,7 +5326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1cfcf399c774719fb1fa51bc6b91e86bdf003b03202a0902f1827ba6750746" dependencies = [ "fontique", - "harfrust 0.8.4", + "harfrust", "hashbrown 0.17.1", "icu_normalizer", "icu_properties", @@ -5357,7 +5334,7 @@ dependencies = [ "linebender_resource_handle", "parlance", "parley_data", - "skrifa 0.42.1", + "skrifa", ] [[package]] @@ -5956,18 +5933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "font-types 0.11.3", -] - -[[package]] -name = "read-fonts" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" -dependencies = [ - "bytemuck", - "font-types 0.12.2", - "once_cell", + "font-types", ] [[package]] @@ -6275,6 +6241,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" @@ -6638,7 +6622,7 @@ checksum = "caad12d9a3f75948b725a643cd60aea6de9d20cb03d3e35221e7a8a31c549409" dependencies = [ "fnv", "hashbrown 0.17.1", - "skrifa 0.42.1", + "skrifa", "thiserror 2.0.18", "write-fonts", ] @@ -6677,17 +6661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", - "read-fonts 0.39.2", -] - -[[package]] -name = "skrifa" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" -dependencies = [ - "bytemuck", - "read-fonts 0.41.0", + "read-fonts", ] [[package]] @@ -7348,7 +7322,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "tiny-skia-path 0.11.4", + "tiny-skia-path", ] [[package]] @@ -7362,17 +7336,6 @@ dependencies = [ "strict-num", ] -[[package]] -name = "tiny-skia-path" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - [[package]] name = "tiny-xlib" version = "0.2.5" @@ -7738,6 +7701,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "ttf2woff2" @@ -7821,6 +7787,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -7836,6 +7814,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-script" version = "0.5.8" @@ -7893,25 +7877,25 @@ dependencies = [ [[package]] name = "usvg" -version = "0.48.1" -source = "git+https://github.com/DioxusLabs/resvg?branch=devin%2F1785858271-intrinsic-dimensions#3289a9b0c3d3352692bf5acdf5f6e6949cdb57b5" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "data-url", "flate2", "fontdb", - "harfrust 0.12.0", "imagesize", "kurbo", "log", "pico-args", "roxmltree 0.21.1", + "rustybuzz", "simplecss", "siphasher", - "skrifa 0.44.0", "strict-num", "svgtypes", - "tiny-skia-path 0.12.0", + "tiny-skia-path", "unicode-bidi", "unicode-script", "unicode-vo", @@ -7981,7 +7965,7 @@ dependencies = [ "log", "peniko", "png 0.18.1", - "skrifa 0.42.1", + "skrifa", "static_assertions", "thiserror 2.0.18", "vello_encoding", @@ -8031,7 +8015,7 @@ dependencies = [ "bytemuck", "guillotiere", "peniko", - "skrifa 0.42.1", + "skrifa", "smallvec", ] @@ -9311,11 +9295,11 @@ version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" dependencies = [ - "font-types 0.11.3", + "font-types", "indexmap", "kurbo", "log", - "read-fonts 0.39.2", + "read-fonts", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a96c530c99..a408d318e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,7 +126,8 @@ linebender_resource_handle = "0.1" peniko = "0.6.0" kurbo = "0.13.1" wgpu = "29" -usvg = "0.48.1" +usvg = "0.46.0" +svgtypes = "0.16.1" # Should match usvg's version # Windowing & Input raw-window-handle = "0.6.0" @@ -270,13 +271,6 @@ color = { workspace = true } env_logger = "0.11" tracing-subscriber = "0.3" -# TODO: remove these patches once `usvg` (with `Tree::intrinsic_dimensions`) and -# `anyrender_svg` (with the usvg 0.48 bump) are released. -[patch.crates-io] -usvg = { git = "https://github.com/DioxusLabs/resvg", branch = "devin/1785858271-intrinsic-dimensions" } -anyrender = { git = "https://github.com/DioxusLabs/anyrender", branch = "devin/1785858394-usvg-048" } -anyrender_svg = { git = "https://github.com/DioxusLabs/anyrender", branch = "devin/1785858394-usvg-048" } - # [patch.crates-io] # anyrender = { path = "../anyrender/crates/anyrender" } # anyrender_skia = { path = "../anyrender/crates/anyrender_skia" } diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index 6fd01d4979..ab86c3b719 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -21,7 +21,7 @@ default = [ ] custom-widget = ["dep:anyrender", "accessibility"] tracing = ["dep:tracing"] -svg = ["dep:usvg"] +svg = ["dep:usvg", "dep:svgtypes"] # WOFF decoding (using the "wuff" crate which is pure Rust) woff = ["dep:wuff"] accessibility = ["accesskit"] @@ -78,6 +78,7 @@ rayon = { workspace = true } # Media & Decoding image = { workspace = true } usvg = { workspace = true, optional = true } +svgtypes = { workspace = true, optional = true } wuff = { workspace = true, optional = true } html-escape = { workspace = true } percent-encoding = { workspace = true } diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index c78babe458..fe3f1414ac 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -723,20 +723,37 @@ impl RasterImageData { } } +/// Dimensions declared on the root `` element, before any resolution. +/// +/// Unlike [`usvg::Tree::size`], which always produces a concrete size, this +/// preserves what the SVG actually declared: absent attributes are `None` and +/// percentage lengths are kept unresolved. +#[cfg(feature = "svg")] +#[derive(Debug, Clone, Copy, Default)] +pub struct SvgIntrinsicDimensions { + /// The root `width` attribute, if declared. Percentages are unresolved. + pub width: Option, + /// The root `height` attribute, if declared. Percentages are unresolved. + pub height: Option, + /// The root `viewBox` width/height, if declared and valid. + pub view_box_size: Option<(f32, f32)>, +} + /// A parsed SVG image. /// /// usvg always resolves the root `` to a concrete [`usvg::Tree::size`], /// falling back to the `viewBox` size when `width`/`height` are absent or given /// as percentages. For CSS sizing purposes, however, such an SVG has *no* /// intrinsic width/height (only an intrinsic aspect ratio). The accessors on -/// this type resolve the CSS intrinsic dimensions lazily from -/// [`usvg::Tree::intrinsic_dimensions`], which preserves what was actually -/// declared on the root element. +/// this type resolve the CSS intrinsic dimensions from the declared root +/// attributes, which are captured at parse time. #[cfg(feature = "svg")] #[derive(Debug, Clone)] pub struct SvgImageData { /// The parsed SVG tree. pub tree: Arc, + /// The dimensions declared on the root `` element. + pub intrinsic_dimensions: SvgIntrinsicDimensions, } #[cfg(feature = "svg")] @@ -744,10 +761,9 @@ impl SvgImageData { /// The intrinsic width in CSS px, present only when the root `` /// declared an absolute (non-percentage) `width`. pub fn intrinsic_width(&self) -> Option { - use usvg::svgtypes::LengthUnit; + use svgtypes::LengthUnit; let declared = self - .tree - .intrinsic_dimensions() + .intrinsic_dimensions .width .is_some_and(|len| len.unit != LengthUnit::Percent); declared.then(|| self.tree.size().width()) @@ -756,10 +772,9 @@ impl SvgImageData { /// The intrinsic height in CSS px, present only when the root `` /// declared an absolute (non-percentage) `height`. pub fn intrinsic_height(&self) -> Option { - use usvg::svgtypes::LengthUnit; + use svgtypes::LengthUnit; let declared = self - .tree - .intrinsic_dimensions() + .intrinsic_dimensions .height .is_some_and(|len| len.unit != LengthUnit::Percent); declared.then(|| self.tree.size().height()) @@ -767,10 +782,7 @@ impl SvgImageData { /// The aspect ratio of the root ``'s `viewBox`, if it declares one. pub fn viewbox_aspect_ratio(&self) -> Option { - self.tree - .intrinsic_dimensions() - .view_box - .map(|vb| vb.width() / vb.height()) + self.intrinsic_dimensions.view_box_size.map(|(w, h)| w / h) } /// The root `width` attribute resolved against a containing block width: @@ -782,8 +794,8 @@ impl SvgImageData { /// (e.g. `` or a background) must use [`Self::intrinsic_width`], /// as its intrinsic dimensions are context-free per CSS. pub fn resolved_width(&self, container_width: Option) -> Option { - use usvg::svgtypes::LengthUnit; - match self.tree.intrinsic_dimensions().width { + use svgtypes::LengthUnit; + match self.intrinsic_dimensions.width { Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().width()), Some(len) => container_width.map(|cw| cw * (len.number as f32) / 100.0), None => None, @@ -793,8 +805,8 @@ impl SvgImageData { /// The root `height` attribute resolved against a containing block height. /// See [`Self::resolved_width`]. pub fn resolved_height(&self, container_height: Option) -> Option { - use usvg::svgtypes::LengthUnit; - match self.tree.intrinsic_dimensions().height { + use svgtypes::LengthUnit; + match self.intrinsic_dimensions.height { Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().height()), Some(len) => container_height.map(|ch| ch * (len.number as f32) / 100.0), None => None, diff --git a/packages/blitz-dom/src/node/mod.rs b/packages/blitz-dom/src/node/mod.rs index 72ff390fe7..50e4bd22ed 100644 --- a/packages/blitz-dom/src/node/mod.rs +++ b/packages/blitz-dom/src/node/mod.rs @@ -14,13 +14,13 @@ pub use attributes::{Attribute, Attributes}; pub use custom_widget::{ ComputedStyles, CustomWidgetData, CustomWidgetStatus, ProxyRenderContext, Widget, }; -#[cfg(feature = "svg")] -pub use element::SvgImageData; pub use element::{ CanvasData, DocumentData, ElementData, ImageData, ImageResourceData, ListItemLayout, ListItemLayoutPosition, Marker, RasterImageData, SpecialElementData, SpecialElementType, Status, }; +#[cfg(feature = "svg")] +pub use element::{SvgImageData, SvgIntrinsicDimensions}; pub use node::*; pub use scrollbar::{ScrollbarColor, ScrollbarRef, ScrollbarWidth}; pub use text::{GeneratedTextInputEvent, TextBrush, TextInputData, TextLayout}; diff --git a/packages/blitz-dom/src/util.rs b/packages/blitz-dom/src/util.rs index c8b86a2f95..354589ee6c 100644 --- a/packages/blitz-dom/src/util.rs +++ b/packages/blitz-dom/src/util.rs @@ -163,9 +163,49 @@ pub(crate) fn parse_svg_image(source: &[u8]) -> Result` element, with absent attributes as `None` and percentages +/// unresolved. [`usvg::Tree`] does not preserve these (it always resolves to a +/// concrete size), so they are re-parsed from the source here. +#[cfg(feature = "svg")] +fn parse_svg_intrinsic_dimensions(source: &[u8]) -> crate::node::SvgIntrinsicDimensions { + use crate::node::SvgIntrinsicDimensions; + + // Non-UTF-8 sources (e.g. gzip-compressed SVGZ) are not handled here: the + // SVG then has no detectable declared dimensions and sizing falls back to + // the resolved `usvg::Tree::size`. + let Ok(text) = std::str::from_utf8(source) else { + return SvgIntrinsicDimensions::default(); + }; + let xml_options = usvg::roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }; + let Ok(doc) = usvg::roxmltree::Document::parse_with_options(text, xml_options) else { + return SvgIntrinsicDimensions::default(); + }; + let root = doc.root_element(); + + let parse_length = |name: &str| -> Option { + root.attribute(name)?.parse::().ok() + }; + let view_box_size = root + .attribute("viewBox") + .and_then(|s| s.parse::().ok()) + .filter(|vb| vb.w.is_finite() && vb.w > 0.0 && vb.h.is_finite() && vb.h > 0.0) + .map(|vb| (vb.w as f32, vb.h as f32)); + + SvgIntrinsicDimensions { + width: parse_length("width"), + height: parse_length("height"), + view_box_size, + } +} + pub trait ToColorColor { /// Converts a color into the `AlphaColor` type from the `color` crate fn as_color_color(&self) -> Color; @@ -192,7 +232,6 @@ mod svg_tests { assert_eq!(svg.intrinsic_height(), None); assert_eq!(svg.viewbox_aspect_ratio(), Some(1.0)); assert_eq!(svg.tree.size().width(), 200.0); - assert_eq!(svg.tree.size().height(), 200.0); assert_eq!(svg.intrinsic_size(), (200.0, 200.0)); } From 1dd669e14b2be703b8be7c41f306f8539081697c Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 12 Aug 2026 15:24:44 +0000 Subject: [PATCH 2/4] Expose SvgIntrinsicDimensions::from_svg_source; fix svg_background_size test --- packages/blitz-dom/src/node/element.rs | 39 ++++++++++++++++++ packages/blitz-dom/src/util.rs | 41 +------------------ .../blitz-tests/tests/svg_background_size.rs | 3 +- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index fe3f1414ac..36254b3a5a 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -739,6 +739,45 @@ pub struct SvgIntrinsicDimensions { pub view_box_size: Option<(f32, f32)>, } +#[cfg(feature = "svg")] +impl SvgIntrinsicDimensions { + /// Extract the `width`/`height`/`viewBox` attributes declared on the root + /// `` element, with absent attributes as `None` and percentages + /// unresolved. [`usvg::Tree`] does not preserve these (it always resolves + /// to a concrete size), so they are re-parsed from the source here. + pub fn from_svg_source(source: &[u8]) -> Self { + // Non-UTF-8 sources (e.g. gzip-compressed SVGZ) are not handled here: + // the SVG then has no detectable declared dimensions and sizing falls + // back to the resolved `usvg::Tree::size`. + let Ok(text) = std::str::from_utf8(source) else { + return Self::default(); + }; + let xml_options = usvg::roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }; + let Ok(doc) = usvg::roxmltree::Document::parse_with_options(text, xml_options) else { + return Self::default(); + }; + let root = doc.root_element(); + + let parse_length = |name: &str| -> Option { + root.attribute(name)?.parse::().ok() + }; + let view_box_size = root + .attribute("viewBox") + .and_then(|s| s.parse::().ok()) + .filter(|vb| vb.w.is_finite() && vb.w > 0.0 && vb.h.is_finite() && vb.h > 0.0) + .map(|vb| (vb.w as f32, vb.h as f32)); + + Self { + width: parse_length("width"), + height: parse_length("height"), + view_box_size, + } + } +} + /// A parsed SVG image. /// /// usvg always resolves the root `` to a concrete [`usvg::Tree::size`], diff --git a/packages/blitz-dom/src/util.rs b/packages/blitz-dom/src/util.rs index 354589ee6c..30412adf84 100644 --- a/packages/blitz-dom/src/util.rs +++ b/packages/blitz-dom/src/util.rs @@ -163,49 +163,10 @@ pub(crate) fn parse_svg_image(source: &[u8]) -> Result` element, with absent attributes as `None` and percentages -/// unresolved. [`usvg::Tree`] does not preserve these (it always resolves to a -/// concrete size), so they are re-parsed from the source here. -#[cfg(feature = "svg")] -fn parse_svg_intrinsic_dimensions(source: &[u8]) -> crate::node::SvgIntrinsicDimensions { - use crate::node::SvgIntrinsicDimensions; - - // Non-UTF-8 sources (e.g. gzip-compressed SVGZ) are not handled here: the - // SVG then has no detectable declared dimensions and sizing falls back to - // the resolved `usvg::Tree::size`. - let Ok(text) = std::str::from_utf8(source) else { - return SvgIntrinsicDimensions::default(); - }; - let xml_options = usvg::roxmltree::ParsingOptions { - allow_dtd: true, - ..Default::default() - }; - let Ok(doc) = usvg::roxmltree::Document::parse_with_options(text, xml_options) else { - return SvgIntrinsicDimensions::default(); - }; - let root = doc.root_element(); - - let parse_length = |name: &str| -> Option { - root.attribute(name)?.parse::().ok() - }; - let view_box_size = root - .attribute("viewBox") - .and_then(|s| s.parse::().ok()) - .filter(|vb| vb.w.is_finite() && vb.w > 0.0 && vb.h.is_finite() && vb.h > 0.0) - .map(|vb| (vb.w as f32, vb.h as f32)); - - SvgIntrinsicDimensions { - width: parse_length("width"), - height: parse_length("height"), - view_box_size, - } -} - pub trait ToColorColor { /// Converts a color into the `AlphaColor` type from the `color` crate fn as_color_color(&self) -> Color; diff --git a/tests/blitz-tests/tests/svg_background_size.rs b/tests/blitz-tests/tests/svg_background_size.rs index 531b04fb88..13ac735c63 100644 --- a/tests/blitz-tests/tests/svg_background_size.rs +++ b/tests/blitz-tests/tests/svg_background_size.rs @@ -9,7 +9,7 @@ use anyrender::render_to_buffer; use anyrender_vello_cpu::VelloCpuImageRenderer; use blitz_dom::DocumentConfig; -use blitz_dom::node::{ImageData, SvgImageData}; +use blitz_dom::node::{ImageData, SvgImageData, SvgIntrinsicDimensions}; use blitz_html::{HtmlDocument, HtmlProvider}; use blitz_paint::paint_scene; use blitz_traits::shell::{ColorScheme, Viewport}; @@ -39,6 +39,7 @@ fn pixel(background: &str, svg_src: &str, x: usize, y: usize) -> [u8; 3] { usvg::Tree::from_str(svg_src, &usvg::Options::default()).expect("valid test SVG"); let svg = SvgImageData { tree: Arc::new(tree), + intrinsic_dimensions: SvgIntrinsicDimensions::from_svg_source(svg_src.as_bytes()), }; let node = doc.get_node_mut(box_id).unwrap(); let el = node.element_data_mut().unwrap(); From bc0c28868ca33396ce2d81127f382e920a0b7825 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 12 Aug 2026 15:35:36 +0000 Subject: [PATCH 3/4] Move SVG image types to node::svg; parse XML once for tree and dimensions --- packages/blitz-dom/src/node/element.rs | 171 +--------------- packages/blitz-dom/src/node/mod.rs | 6 +- packages/blitz-dom/src/node/svg.rs | 188 ++++++++++++++++++ packages/blitz-dom/src/util.rs | 6 +- .../blitz-tests/tests/svg_background_size.rs | 10 +- 5 files changed, 198 insertions(+), 183 deletions(-) create mode 100644 packages/blitz-dom/src/node/svg.rs diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index 36254b3a5a..e036ae0851 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -28,6 +28,8 @@ use taffy::{ use url::Url; use super::stylo_data::StyloData; +#[cfg(feature = "svg")] +use super::svg::SvgImageData; use super::{Attribute, Attributes}; use crate::Document; use crate::layout::table::TableContext; @@ -723,175 +725,6 @@ impl RasterImageData { } } -/// Dimensions declared on the root `` element, before any resolution. -/// -/// Unlike [`usvg::Tree::size`], which always produces a concrete size, this -/// preserves what the SVG actually declared: absent attributes are `None` and -/// percentage lengths are kept unresolved. -#[cfg(feature = "svg")] -#[derive(Debug, Clone, Copy, Default)] -pub struct SvgIntrinsicDimensions { - /// The root `width` attribute, if declared. Percentages are unresolved. - pub width: Option, - /// The root `height` attribute, if declared. Percentages are unresolved. - pub height: Option, - /// The root `viewBox` width/height, if declared and valid. - pub view_box_size: Option<(f32, f32)>, -} - -#[cfg(feature = "svg")] -impl SvgIntrinsicDimensions { - /// Extract the `width`/`height`/`viewBox` attributes declared on the root - /// `` element, with absent attributes as `None` and percentages - /// unresolved. [`usvg::Tree`] does not preserve these (it always resolves - /// to a concrete size), so they are re-parsed from the source here. - pub fn from_svg_source(source: &[u8]) -> Self { - // Non-UTF-8 sources (e.g. gzip-compressed SVGZ) are not handled here: - // the SVG then has no detectable declared dimensions and sizing falls - // back to the resolved `usvg::Tree::size`. - let Ok(text) = std::str::from_utf8(source) else { - return Self::default(); - }; - let xml_options = usvg::roxmltree::ParsingOptions { - allow_dtd: true, - ..Default::default() - }; - let Ok(doc) = usvg::roxmltree::Document::parse_with_options(text, xml_options) else { - return Self::default(); - }; - let root = doc.root_element(); - - let parse_length = |name: &str| -> Option { - root.attribute(name)?.parse::().ok() - }; - let view_box_size = root - .attribute("viewBox") - .and_then(|s| s.parse::().ok()) - .filter(|vb| vb.w.is_finite() && vb.w > 0.0 && vb.h.is_finite() && vb.h > 0.0) - .map(|vb| (vb.w as f32, vb.h as f32)); - - Self { - width: parse_length("width"), - height: parse_length("height"), - view_box_size, - } - } -} - -/// A parsed SVG image. -/// -/// usvg always resolves the root `` to a concrete [`usvg::Tree::size`], -/// falling back to the `viewBox` size when `width`/`height` are absent or given -/// as percentages. For CSS sizing purposes, however, such an SVG has *no* -/// intrinsic width/height (only an intrinsic aspect ratio). The accessors on -/// this type resolve the CSS intrinsic dimensions from the declared root -/// attributes, which are captured at parse time. -#[cfg(feature = "svg")] -#[derive(Debug, Clone)] -pub struct SvgImageData { - /// The parsed SVG tree. - pub tree: Arc, - /// The dimensions declared on the root `` element. - pub intrinsic_dimensions: SvgIntrinsicDimensions, -} - -#[cfg(feature = "svg")] -impl SvgImageData { - /// The intrinsic width in CSS px, present only when the root `` - /// declared an absolute (non-percentage) `width`. - pub fn intrinsic_width(&self) -> Option { - use svgtypes::LengthUnit; - let declared = self - .intrinsic_dimensions - .width - .is_some_and(|len| len.unit != LengthUnit::Percent); - declared.then(|| self.tree.size().width()) - } - - /// The intrinsic height in CSS px, present only when the root `` - /// declared an absolute (non-percentage) `height`. - pub fn intrinsic_height(&self) -> Option { - use svgtypes::LengthUnit; - let declared = self - .intrinsic_dimensions - .height - .is_some_and(|len| len.unit != LengthUnit::Percent); - declared.then(|| self.tree.size().height()) - } - - /// The aspect ratio of the root ``'s `viewBox`, if it declares one. - pub fn viewbox_aspect_ratio(&self) -> Option { - self.intrinsic_dimensions.view_box_size.map(|(w, h)| w / h) - } - - /// The root `width` attribute resolved against a containing block width: - /// percentages resolve against the containing block (`None` if it is - /// indefinite) and an absent attribute is `None`. - /// - /// This is only appropriate for an inline `` element, where the - /// attributes behave as presentation attributes. SVG used as an image - /// (e.g. `` or a background) must use [`Self::intrinsic_width`], - /// as its intrinsic dimensions are context-free per CSS. - pub fn resolved_width(&self, container_width: Option) -> Option { - use svgtypes::LengthUnit; - match self.intrinsic_dimensions.width { - Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().width()), - Some(len) => container_width.map(|cw| cw * (len.number as f32) / 100.0), - None => None, - } - } - - /// The root `height` attribute resolved against a containing block height. - /// See [`Self::resolved_width`]. - pub fn resolved_height(&self, container_height: Option) -> Option { - use svgtypes::LengthUnit; - match self.intrinsic_dimensions.height { - Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().height()), - Some(len) => container_height.map(|ch| ch * (len.number as f32) / 100.0), - None => None, - } - } - - /// The intrinsic aspect ratio of the SVG: the ratio of its declared - /// `width`/`height` when both are absolute lengths, otherwise the - /// `viewBox` ratio, otherwise the ratio of the resolved - /// [`usvg::Tree::size`] (which is always non-zero). - pub fn aspect_ratio(&self) -> f32 { - match (self.intrinsic_width(), self.intrinsic_height()) { - (Some(w), Some(h)) => w / h, - _ => self.viewbox_aspect_ratio().unwrap_or_else(|| { - let size = self.tree.size(); - size.width() / size.height() - }), - } - } - - /// The intrinsic dimensions of the SVG resolved per CSS replaced element - /// sizing: a missing dimension is computed from the declared one and the - /// intrinsic aspect ratio; if neither is declared, the resolved - /// [`usvg::Tree::size`] is used as a fallback. - pub fn intrinsic_size(&self) -> (f32, f32) { - let aspect_ratio = self.aspect_ratio(); - match (self.intrinsic_width(), self.intrinsic_height()) { - (Some(w), Some(h)) => (w, h), - (Some(w), None) => (w, w / aspect_ratio), - (None, Some(h)) => (h * aspect_ratio, h), - (None, None) => { - // No intrinsic dimensions. If there is an intrinsic aspect ratio, apply - // the CSS default sizing algorithm: contain within the default object - // size of 300x150. Otherwise fall back to the resolved tree size. - if self.viewbox_aspect_ratio().is_some() { - let scale = (300.0 / aspect_ratio).min(150.0); - (scale * aspect_ratio, scale) - } else { - let size = self.tree.size(); - (size.width(), size.height()) - } - } - } - } -} - #[derive(Debug, Clone)] pub enum ImageData { Raster(RasterImageData), diff --git a/packages/blitz-dom/src/node/mod.rs b/packages/blitz-dom/src/node/mod.rs index 50e4bd22ed..d830fc9d53 100644 --- a/packages/blitz-dom/src/node/mod.rs +++ b/packages/blitz-dom/src/node/mod.rs @@ -7,6 +7,8 @@ mod element; mod node; pub(crate) mod scrollbar; mod stylo_data; +#[cfg(feature = "svg")] +mod svg; mod text; pub use attributes::{Attribute, Attributes}; @@ -19,8 +21,8 @@ pub use element::{ ListItemLayoutPosition, Marker, RasterImageData, SpecialElementData, SpecialElementType, Status, }; -#[cfg(feature = "svg")] -pub use element::{SvgImageData, SvgIntrinsicDimensions}; pub use node::*; pub use scrollbar::{ScrollbarColor, ScrollbarRef, ScrollbarWidth}; +#[cfg(feature = "svg")] +pub use svg::{SvgImageData, SvgIntrinsicDimensions}; pub use text::{GeneratedTextInputEvent, TextBrush, TextInputData, TextLayout}; diff --git a/packages/blitz-dom/src/node/svg.rs b/packages/blitz-dom/src/node/svg.rs new file mode 100644 index 0000000000..cd29b6bd38 --- /dev/null +++ b/packages/blitz-dom/src/node/svg.rs @@ -0,0 +1,188 @@ +//! SVG image data and CSS intrinsic sizing for SVG. + +use std::sync::Arc; + +use usvg::roxmltree; + +/// Dimensions declared on the root `` element, before any resolution. +/// +/// Unlike [`usvg::Tree::size`], which always produces a concrete size, this +/// preserves what the SVG actually declared: absent attributes are `None` and +/// percentage lengths are kept unresolved. +#[derive(Debug, Clone, Copy, Default)] +pub struct SvgIntrinsicDimensions { + /// The root `width` attribute, if declared. Percentages are unresolved. + pub width: Option, + /// The root `height` attribute, if declared. Percentages are unresolved. + pub height: Option, + /// The root `viewBox` width/height, if declared and valid. + pub view_box_size: Option<(f32, f32)>, +} + +impl SvgIntrinsicDimensions { + /// Extract the `width`/`height`/`viewBox` attributes declared on the root + /// element of an already-parsed SVG document, with absent attributes as + /// `None` and percentages unresolved. [`usvg::Tree`] does not preserve + /// these (it always resolves to a concrete size), so they are read from + /// the XML document here. + pub fn from_xmltree(doc: &roxmltree::Document) -> Self { + let root = doc.root_element(); + + let parse_length = |name: &str| -> Option { + root.attribute(name)?.parse::().ok() + }; + let view_box_size = root + .attribute("viewBox") + .and_then(|s| s.parse::().ok()) + .filter(|vb| vb.w.is_finite() && vb.w > 0.0 && vb.h.is_finite() && vb.h > 0.0) + .map(|vb| (vb.w as f32, vb.h as f32)); + + Self { + width: parse_length("width"), + height: parse_length("height"), + view_box_size, + } + } +} + +/// A parsed SVG image. +/// +/// usvg always resolves the root `` to a concrete [`usvg::Tree::size`], +/// falling back to the `viewBox` size when `width`/`height` are absent or given +/// as percentages. For CSS sizing purposes, however, such an SVG has *no* +/// intrinsic width/height (only an intrinsic aspect ratio). The accessors on +/// this type resolve the CSS intrinsic dimensions from the declared root +/// attributes, which are captured at parse time. +#[derive(Debug, Clone)] +pub struct SvgImageData { + /// The parsed SVG tree. + pub tree: Arc, + /// The dimensions declared on the root `` element. + pub intrinsic_dimensions: SvgIntrinsicDimensions, +} + +impl SvgImageData { + /// Parse an SVG image from raw data, capturing both the rendered + /// [`usvg::Tree`] and the declared root dimensions from a single XML + /// parse. + /// + /// Gzip-compressed data (SVGZ) is delegated to [`usvg::Tree::from_data`]; + /// its declared dimensions are not recovered, so sizing falls back to the + /// resolved [`usvg::Tree::size`]. + pub fn from_data(data: &[u8], options: &usvg::Options) -> Result { + if data.starts_with(&[0x1f, 0x8b]) { + let tree = usvg::Tree::from_data(data, options)?; + return Ok(Self { + tree: Arc::new(tree), + intrinsic_dimensions: SvgIntrinsicDimensions::default(), + }); + } + + let text = std::str::from_utf8(data).map_err(|_| usvg::Error::NotAnUtf8Str)?; + let xml_options = roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }; + let doc = roxmltree::Document::parse_with_options(text, xml_options) + .map_err(usvg::Error::ParsingFailed)?; + let tree = usvg::Tree::from_xmltree(&doc, options)?; + Ok(Self { + tree: Arc::new(tree), + intrinsic_dimensions: SvgIntrinsicDimensions::from_xmltree(&doc), + }) + } + + /// The intrinsic width in CSS px, present only when the root `` + /// declared an absolute (non-percentage) `width`. + pub fn intrinsic_width(&self) -> Option { + use svgtypes::LengthUnit; + let declared = self + .intrinsic_dimensions + .width + .is_some_and(|len| len.unit != LengthUnit::Percent); + declared.then(|| self.tree.size().width()) + } + + /// The intrinsic height in CSS px, present only when the root `` + /// declared an absolute (non-percentage) `height`. + pub fn intrinsic_height(&self) -> Option { + use svgtypes::LengthUnit; + let declared = self + .intrinsic_dimensions + .height + .is_some_and(|len| len.unit != LengthUnit::Percent); + declared.then(|| self.tree.size().height()) + } + + /// The aspect ratio of the root ``'s `viewBox`, if it declares one. + pub fn viewbox_aspect_ratio(&self) -> Option { + self.intrinsic_dimensions.view_box_size.map(|(w, h)| w / h) + } + + /// The root `width` attribute resolved against a containing block width: + /// percentages resolve against the containing block (`None` if it is + /// indefinite) and an absent attribute is `None`. + /// + /// This is only appropriate for an inline `` element, where the + /// attributes behave as presentation attributes. SVG used as an image + /// (e.g. `` or a background) must use [`Self::intrinsic_width`], + /// as its intrinsic dimensions are context-free per CSS. + pub fn resolved_width(&self, container_width: Option) -> Option { + use svgtypes::LengthUnit; + match self.intrinsic_dimensions.width { + Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().width()), + Some(len) => container_width.map(|cw| cw * (len.number as f32) / 100.0), + None => None, + } + } + + /// The root `height` attribute resolved against a containing block height. + /// See [`Self::resolved_width`]. + pub fn resolved_height(&self, container_height: Option) -> Option { + use svgtypes::LengthUnit; + match self.intrinsic_dimensions.height { + Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().height()), + Some(len) => container_height.map(|ch| ch * (len.number as f32) / 100.0), + None => None, + } + } + + /// The intrinsic aspect ratio of the SVG: the ratio of its declared + /// `width`/`height` when both are absolute lengths, otherwise the + /// `viewBox` ratio, otherwise the ratio of the resolved + /// [`usvg::Tree::size`] (which is always non-zero). + pub fn aspect_ratio(&self) -> f32 { + match (self.intrinsic_width(), self.intrinsic_height()) { + (Some(w), Some(h)) => w / h, + _ => self.viewbox_aspect_ratio().unwrap_or_else(|| { + let size = self.tree.size(); + size.width() / size.height() + }), + } + } + + /// The intrinsic dimensions of the SVG resolved per CSS replaced element + /// sizing: a missing dimension is computed from the declared one and the + /// intrinsic aspect ratio; if neither is declared, the resolved + /// [`usvg::Tree::size`] is used as a fallback. + pub fn intrinsic_size(&self) -> (f32, f32) { + let aspect_ratio = self.aspect_ratio(); + match (self.intrinsic_width(), self.intrinsic_height()) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, w / aspect_ratio), + (None, Some(h)) => (h * aspect_ratio, h), + (None, None) => { + // No intrinsic dimensions. If there is an intrinsic aspect ratio, apply + // the CSS default sizing algorithm: contain within the default object + // size of 300x150. Otherwise fall back to the resolved tree size. + if self.viewbox_aspect_ratio().is_some() { + let scale = (300.0 / aspect_ratio).min(150.0); + (scale * aspect_ratio, scale) + } else { + let size = self.tree.size(); + (size.width(), size.height()) + } + } + } + } +} diff --git a/packages/blitz-dom/src/util.rs b/packages/blitz-dom/src/util.rs index 30412adf84..dcad882a64 100644 --- a/packages/blitz-dom/src/util.rs +++ b/packages/blitz-dom/src/util.rs @@ -160,11 +160,7 @@ pub(crate) fn parse_svg_image(source: &[u8]) -> Result [u8; 3] { doc.resolve(0.0); let box_id = doc.query_selector("#box").unwrap().expect("#box"); { - let tree = - usvg::Tree::from_str(svg_src, &usvg::Options::default()).expect("valid test SVG"); - let svg = SvgImageData { - tree: Arc::new(tree), - intrinsic_dimensions: SvgIntrinsicDimensions::from_svg_source(svg_src.as_bytes()), - }; + let svg = SvgImageData::from_data(svg_src.as_bytes(), &usvg::Options::default()) + .expect("valid test SVG"); let node = doc.get_node_mut(box_id).unwrap(); let el = node.element_data_mut().unwrap(); for layer in el.background_images.iter_mut().flatten() { From 74e835867a37045a6f4b3493a91446ba5fc3a060 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 12 Aug 2026 15:38:33 +0000 Subject: [PATCH 4/4] Decompress SVGZ in SvgImageData::from_data via usvg::decompress_svgz --- packages/blitz-dom/src/node/svg.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/blitz-dom/src/node/svg.rs b/packages/blitz-dom/src/node/svg.rs index cd29b6bd38..54d6975aa9 100644 --- a/packages/blitz-dom/src/node/svg.rs +++ b/packages/blitz-dom/src/node/svg.rs @@ -66,17 +66,17 @@ impl SvgImageData { /// [`usvg::Tree`] and the declared root dimensions from a single XML /// parse. /// - /// Gzip-compressed data (SVGZ) is delegated to [`usvg::Tree::from_data`]; - /// its declared dimensions are not recovered, so sizing falls back to the - /// resolved [`usvg::Tree::size`]. + /// Like [`usvg::Tree::from_data`], gzip-compressed data (SVGZ) is + /// decompressed first. pub fn from_data(data: &[u8], options: &usvg::Options) -> Result { - if data.starts_with(&[0x1f, 0x8b]) { - let tree = usvg::Tree::from_data(data, options)?; - return Ok(Self { - tree: Arc::new(tree), - intrinsic_dimensions: SvgIntrinsicDimensions::default(), - }); - } + // Gzip magic bytes, matching the SVGZ detection in `usvg::Tree::from_data`. + let decompressed; + let data = if data.starts_with(&[0x1f, 0x8b]) { + decompressed = usvg::decompress_svgz(data)?; + decompressed.as_slice() + } else { + data + }; let text = std::str::from_utf8(data).map_err(|_| usvg::Error::NotAnUtf8Str)?; let xml_options = roxmltree::ParsingOptions {