From ef633105c6edd5e5c913ee5afec414c72b289686 Mon Sep 17 00:00:00 2001 From: Ritesh Date: Wed, 12 Aug 2026 13:13:17 +0000 Subject: [PATCH 1/5] Add first-party inline SVG rendering (svg-native feature) --- .github/workflows/ci.yml | 41 ++ Cargo.lock | 1 + packages/blitz-dom/Cargo.toml | 2 + packages/blitz-dom/assets/default.css | 43 ++ packages/blitz-dom/src/accessibility.rs | 26 + packages/blitz-dom/src/layout/construct.rs | 47 +- packages/blitz-dom/src/layout/damage.rs | 37 +- packages/blitz-dom/src/layout/mod.rs | 28 + packages/blitz-dom/src/lib.rs | 2 + packages/blitz-dom/src/mutator.rs | 12 + packages/blitz-dom/src/node/element.rs | 17 + packages/blitz-dom/src/node/node.rs | 27 + packages/blitz-dom/src/resolve.rs | 4 + packages/blitz-dom/src/stylo.rs | 67 ++- packages/blitz-dom/src/svg/attrs.rs | 175 ++++++ packages/blitz-dom/src/svg/construct.rs | 535 +++++++++++++++++++ packages/blitz-dom/src/svg/context.rs | 110 ++++ packages/blitz-dom/src/svg/geometry.rs | 341 ++++++++++++ packages/blitz-dom/src/svg/hit_test.rs | 119 +++++ packages/blitz-dom/src/svg/mod.rs | 15 + packages/blitz-dom/src/svg/resolve.rs | 127 +++++ packages/blitz-dom/src/svg/text.rs | 141 +++++ packages/blitz-dom/src/svg/viewport.rs | 190 +++++++ packages/blitz-paint/Cargo.toml | 2 + packages/blitz-paint/src/render.rs | 12 +- packages/blitz-paint/src/render/svg.rs | 394 ++++++++++++++ tests/blitz-tests/Cargo.toml | 5 +- tests/blitz-tests/tests/svg_group_opacity.rs | 93 ++++ tests/blitz-tests/tests/svg_native.rs | 180 +++++++ 29 files changed, 2775 insertions(+), 18 deletions(-) create mode 100644 packages/blitz-dom/src/svg/attrs.rs create mode 100644 packages/blitz-dom/src/svg/construct.rs create mode 100644 packages/blitz-dom/src/svg/context.rs create mode 100644 packages/blitz-dom/src/svg/geometry.rs create mode 100644 packages/blitz-dom/src/svg/hit_test.rs create mode 100644 packages/blitz-dom/src/svg/mod.rs create mode 100644 packages/blitz-dom/src/svg/resolve.rs create mode 100644 packages/blitz-dom/src/svg/text.rs create mode 100644 packages/blitz-dom/src/svg/viewport.rs create mode 100644 packages/blitz-paint/src/render/svg.rs create mode 100644 tests/blitz-tests/tests/svg_group_opacity.rs create mode 100644 tests/blitz-tests/tests/svg_native.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09576ef314..df6e77611d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,47 @@ jobs: version: 1.0 - run: cargo test --workspace + # The 4 feature states of blitz-dom/blitz-paint's `svg` (usvg) x + # `svg-native` (first-party) flags must all compile and pass. Scoped to + # `-p blitz-dom -p blitz-paint` (not `--workspace`) so the features + # actually reach those crates instead of resolving against the root + # workspace default-members, which don't re-export them. + test-svg-feature-matrix: + name: "Test [svg=${{ matrix.svg }}, svg-native=${{ matrix.svg-native }}]" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + svg: [true, false] + svg-native: [true, false] + exclude: + # Both off doesn't build (there would be no SVG support at all + # and blitz-paint's default feature set assumes at least one). + - svg: false + svg-native: false + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: perl -pi.bak -e 's/opt-level = 2/opt-level = 0/g' Cargo.toml + - uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: libfontconfig-dev + version: 1.0 + - name: Build feature flags + id: features + run: | + FEATURES="" + if [ "${{ matrix.svg }}" = "true" ]; then + FEATURES="${FEATURES}svg," + fi + if [ "${{ matrix.svg-native }}" = "true" ]; then + FEATURES="${FEATURES}svg-native," + fi + FEATURES="${FEATURES%,}" + echo "flag=--no-default-features --features ${FEATURES}" >> "$GITHUB_OUTPUT" + - run: cargo test -p blitz-dom -p blitz-paint ${{ steps.features.outputs.flag }} + - run: cargo build -p blitz-dom -p blitz-paint ${{ steps.features.outputs.flag }} + build-counter: name: "Build counter example" runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 824ad92544..dbe082243b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,6 +1046,7 @@ dependencies = [ "dioxus-core", "dioxus-native-dom", "keyboard-types 0.7.0", + "kurbo", "markup5ever", "usvg", ] diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index 6fd01d4979..572919fbe8 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -22,6 +22,8 @@ default = [ custom-widget = ["dep:anyrender", "accessibility"] tracing = ["dep:tracing"] svg = ["dep:usvg"] +# First-party inline rendering. Default off; additive-only. +svg-native = [] # WOFF decoding (using the "wuff" crate which is pure Rust) woff = ["dep:wuff"] accessibility = ["accesskit"] diff --git a/packages/blitz-dom/assets/default.css b/packages/blitz-dom/assets/default.css index 8fef4b6e2f..2d52b153f5 100644 --- a/packages/blitz-dom/assets/default.css +++ b/packages/blitz-dom/assets/default.css @@ -5,6 +5,7 @@ @namespace url(http://www.w3.org/1999/xhtml); /* set default namespace to HTML */ @namespace xul url(http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul); +@namespace svg url(http://www.w3.org/2000/svg); @font-face { font-family: -moz-bullet-font; @@ -1102,3 +1103,45 @@ slot { background-color: transparent; } } + +/* SVG (svg-native): shape from Gecko's layout/style/res/svg.css. + * `display:none` on non-rendered containers means the normal box/paint + * traversal never needs to special-case them -- the cascade already hides + * them; ``/gradient/etc lookups go through `id_map`, not a render + * walk. Note: no `pointer-events` UA rule here -- the full keyword set + * (`visiblePainted` etc) is gecko-only on this Stylo build (see + * svg/hit_test.rs), so hit-testing hardcodes that semantics for `auto` + * directly rather than via an (unparseable) UA declaration. */ +svg|svg { + display: inline; +} + +svg|svg:not(:root), +svg|symbol, +svg|image, +svg|marker, +svg|pattern, +svg|foreignObject { + overflow: hidden; +} + +svg|defs, +svg|clipPath, +svg|marker, +svg|mask, +svg|pattern, +svg|symbol, +svg|filter, +svg|linearGradient, +svg|radialGradient, +svg|stop, +svg|title, +svg|desc, +svg|metadata { + display: none; +} + +svg|foreignObject { + display: block; +} + diff --git a/packages/blitz-dom/src/accessibility.rs b/packages/blitz-dom/src/accessibility.rs index cbd5aefc01..62a94bbc85 100644 --- a/packages/blitz-dom/src/accessibility.rs +++ b/packages/blitz-dom/src/accessibility.rs @@ -45,6 +45,32 @@ impl BaseDocument { } else if let Some(element_data) = node.element_data() { let name = element_data.name.local.to_string(); + // SVG-ns elements get their own role mapping so an HTML element that happens to + // share a local name with an SVG one is never affected by it, and vice versa. + // Falls through to the same `push_child`/return tail as the HTML path below. + #[cfg(feature = "svg-native")] + if element_data.name.ns == markup5ever::ns!(svg) { + let role = match &*name { + "svg" => { + let has_title = node.children.iter().any(|&c| { + self.get_node(c).is_some_and(|n| { + n.data.is_element_with_tag_name(&local_name!("title")) + }) + }); + if has_title { + Role::GraphicsDocument + } else { + Role::Image + } + } + "a" => Role::Link, + _ => Role::GenericContainer, + }; + builder.set_role(role); + parent.push_child(id); + return (id, builder); + } + // let role = match &*name { // Document structure diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index c9d380011f..bdcb5d7568 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -380,7 +380,52 @@ pub(crate) fn collect_layout_children( } } - #[cfg(feature = "svg")] + // First-party inline SVG (`svg-native`): the root `` becomes a + // `SpecialElementData::SvgRoot` marker here; its actual geometry + // tree is built by `svg::construct::rebuild_svg_fragments` *after* + // Taffy layout (its content-box size, needed to resolve `viewBox`/ + // percentages, isn't known until then, see that function's doc comment). + #[cfg(feature = "svg-native")] + if matches!(tag_name, "svg") + && el.name.ns == markup5ever::ns!(svg) + && doc.nodes[container_node_id] + .parent + .map(|p| { + doc.nodes[p] + .data + .downcast_element() + .map(|e| e.name.ns != markup5ever::ns!(svg)) + .unwrap_or(true) + }) + .unwrap_or(true) + { + doc.iter_subtree_mut(container_node_id, |id: NodeId, doc: &mut BaseDocument| { + doc.nodes[id].remove_damage(CONSTRUCT_BOX | CONSTRUCT_DESCENDENT | CONSTRUCT_FC); + }); + if let Some(element_data) = doc + .get_node_mut(container_node_id) + .unwrap() + .element_data_mut() + { + if !matches!(element_data.special_data, SpecialElementData::SvgRoot(_)) { + element_data.special_data = + SpecialElementData::SvgRoot(std::sync::Arc::new(crate::svg::SvgContext { + root: container_node_id, + viewport: kurbo::Size::ZERO, + viewbox: None, + preserve_aspect_ratio: Default::default(), + root_ctm: kurbo::Affine::IDENTITY, + nodes: Vec::new(), + id_map: Default::default(), + })); + } + element_data.take_inline_layout(); + } + doc.nodes[container_node_id].insert_damage(crate::layout::damage::CONSTRUCT_SVG); + return; + } + + #[cfg(all(feature = "svg", not(feature = "svg-native")))] if matches!(tag_name, "svg") { let mut outer_html = doc.get_node(container_node_id).unwrap().outer_html(); diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index 27b72643af..cfde8cd068 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -29,8 +29,41 @@ pub(crate) const CONSTRUCT_DESCENDENT: RestyleDamage = pub(crate) const ONLY_RELAYOUT: RestyleDamage = RestyleDamage::from_bits_retain(0b_0000_0000_0000_1000); -pub(crate) const ALL_DAMAGE: RestyleDamage = - RestyleDamage::from_bits_retain(0b_0000_0000_0111_1111); +/// A mutation somewhere inside an SVG fragment (`svg-native`) requires that +/// fragment's `SvgContext` to be rebuilt. Scoped separately from `CONSTRUCT_BOX` +/// so that mutating one `` root doesn't force a `usvg`-style whole-subtree +/// HTML box reconstruction, and so sibling `` roots are left untouched. +pub(crate) const CONSTRUCT_SVG: RestyleDamage = + RestyleDamage::from_bits_retain(0b_0000_0000_1000_0000); + +pub(crate) const ALL_DAMAGE: RestyleDamage = RestyleDamage::from_bits_retain( + CONSTRUCT_BOX.bits() + | CONSTRUCT_FC.bits() + | CONSTRUCT_DESCENDENT.bits() + | ONLY_RELAYOUT.bits() + | CONSTRUCT_SVG.bits(), +); + +#[cfg(feature = "svg-native")] +impl BaseDocument { + /// Walk `node_id`'s DOM `parent` chain (SVG descendants have no Taffy + /// `layout_parent`) to find the owning `` root fragment and mark it for rebuild. + /// A no-op if `node_id` is not inside an SVG fragment at all. + pub fn propagate_svg_damage(&mut self, node_id: blitz_traits::node_id::NodeId) { + use crate::node::SpecialElementData; + + let mut cur = Some(node_id); + while let Some(id) = cur { + if let Some(elem) = self.nodes[id].data.downcast_element() { + if matches!(elem.special_data, SpecialElementData::SvgRoot(_)) { + self.nodes[id].insert_damage(CONSTRUCT_SVG); + return; + } + } + cur = self.nodes[id].parent; + } + } +} impl BaseDocument { pub(crate) fn propagate_damage_flags( diff --git a/packages/blitz-dom/src/layout/mod.rs b/packages/blitz-dom/src/layout/mod.rs index ddea81262b..6f6f70159d 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -234,6 +234,34 @@ impl BaseDocument { } ImageData::None => (taffy::Size::ZERO, None), }, + // `svg-native`: intrinsic size/ratio for a root `` comes from + // its `viewBox`, read directly off the raw attribute since the `SvgContext` + // itself is only built *after* this layout pass completes. + #[cfg(feature = "svg-native")] + SpecialElementData::SvgRoot(_) => { + let viewbox = + crate::svg::attrs::raw_attr(element_data.attrs(), "viewBox") + .and_then(crate::svg::viewport::parse_viewbox); + match viewbox { + Some(vb) if vb.width() > 0.0 && vb.height() > 0.0 => { + let ratio = (vb.width() / vb.height()) as f32; + ( + taffy::Size { + width: 300.0, + height: 300.0 / ratio, + }, + Some(ratio), + ) + } + _ => ( + taffy::Size { + width: 300.0, + height: 150.0, + }, + None, + ), + } + } // Canvas has an intrinsic size and aspect ratio given by its // width/height attributes, defaulting to 300x150. Other replaced // elements without intrinsic dimensions (video, iframe, embed) use diff --git a/packages/blitz-dom/src/lib.rs b/packages/blitz-dom/src/lib.rs index 97edef089c..11b6418a62 100644 --- a/packages/blitz-dom/src/lib.rs +++ b/packages/blitz-dom/src/lib.rs @@ -58,6 +58,8 @@ mod stylo; mod stylo_to_cursor_icon; mod stylo_to_kurbo; mod stylo_to_parley; +#[cfg(feature = "svg-native")] +pub mod svg; mod traversal; /// Versioned storage for the nodes of the DOM tree. mod tree; diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 135918c1d3..3c6ccdff1a 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -274,6 +274,16 @@ impl DocumentMutator<'_> { } } + // Attribute change in SVG-ns element inside an inline fragment invalidates that fragment's `SvgContext`. + #[cfg(feature = "svg-native")] + if node_is_in_document + && self.doc.nodes[node_id] + .element_data() + .is_some_and(|element| element.name.ns == markup5ever::ns!(svg)) + { + self.doc.propagate_svg_damage(node_id); + } + let node = &mut self.doc.nodes[node_id]; let NodeData::Element(ref mut element) = node.data else { @@ -867,6 +877,8 @@ impl<'doc> DocumentMutator<'doc> { SpecialElementData::CheckboxInput(_) => {} #[cfg(feature = "file-input")] SpecialElementData::FileInput(_) => {} + #[cfg(feature = "svg-native")] + SpecialElementData::SvgRoot(_) => {} SpecialElementData::None => {} } }); diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index c78babe458..49eea55642 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -301,6 +301,11 @@ pub enum SpecialElementData { Canvas(CanvasData), /// Pre-computed table layout data TableRoot(Arc), + /// First-party inline `` fragment (`svg-native` feature only). + /// External SVG (``, background images) keeps using + /// `Image(Box)` via `usvg` regardless of this feature. + #[cfg(feature = "svg-native")] + SvgRoot(Arc), /// Parley text editor (text inputs) TextInput(TextInputData), /// Checkbox checked state @@ -323,6 +328,8 @@ impl Clone for SpecialElementData { Self::Image(data) => Self::Image(data.clone()), Self::Canvas(data) => Self::Canvas(data.clone()), Self::TableRoot(data) => Self::TableRoot(data.clone()), + #[cfg(feature = "svg-native")] + Self::SvgRoot(data) => Self::SvgRoot(data.clone()), Self::TextInput(data) => Self::TextInput(data.clone()), Self::CheckboxInput(data) => Self::CheckboxInput(*data), #[cfg(feature = "file-input")] @@ -472,6 +479,14 @@ impl ElementData { } } + #[cfg(feature = "svg-native")] + pub fn svg_root_data(&self) -> Option<&crate::svg::SvgContext> { + match &self.special_data { + SpecialElementData::SvgRoot(ctx) => Some(ctx), + _ => None, + } + } + pub fn text_input_data(&self) -> Option<&TextInputData> { match &self.special_data { SpecialElementData::TextInput(data) => Some(data), @@ -896,6 +911,8 @@ impl std::fmt::Debug for SpecialElementData { }, SpecialElementData::Canvas(_) => f.write_str("NodeSpecificData::Canvas"), SpecialElementData::TableRoot(_) => f.write_str("NodeSpecificData::TableRoot"), + #[cfg(feature = "svg-native")] + SpecialElementData::SvgRoot(_) => f.write_str("NodeSpecificData::SvgRoot"), SpecialElementData::TextInput(_) => f.write_str("NodeSpecificData::TextInput"), SpecialElementData::CheckboxInput(_) => f.write_str("NodeSpecificData::CheckboxInput"), #[cfg(feature = "file-input")] diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index f253ff23fe..2a4e3ee9e8 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1281,6 +1281,33 @@ impl Node { } } + // svg-native: delegate into the fragment's own SVG-space hit test rather than iterating `paint_children`. + #[cfg(feature = "svg-native")] + if matches_self && !pointer_events_none { + if let Some(element_data) = self.element_data() { + if let crate::node::SpecialElementData::SvgRoot(ctx) = &element_data.special_data { + let content_box_offset = taffy::Point { + x: self.final_layout().padding.left + self.final_layout().border.left, + y: self.final_layout().padding.top + self.final_layout().border.top, + }; + let local_x = x - content_box_offset.x; + let local_y = y - content_box_offset.y; + if let Some(hit_id) = crate::svg::hit_test::hit_test( + self, + ctx, + kurbo::Point::new(local_x as f64, local_y as f64), + ) { + return Some(HitResult { + node_id: hit_id, + x: local_x, + y: local_y, + is_text: false, + }); + } + } + } + } + // Self (this node) if matches_self && !pointer_events_none { return Some(HitResult { diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index 48a125d7bd..8c3184e3ea 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -400,6 +400,10 @@ impl BaseDocument { taffy::compute_root_layout(self, root_element_id, available_space); taffy::round_layout(self, root_element_id); + // Build/rebuild inline SVG fragments now that `` root(s) have a final content-box size. + #[cfg(feature = "svg-native")] + crate::svg::rebuild_svg_fragments(self); + // println!("\n\n"); // taffy::print_tree(self, root_node_id) } diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index c1103aaa53..e4916461cc 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -57,10 +57,23 @@ use style_dom::ElementState; use style::values::computed::text::TextAlign as StyloTextAlign; +// `synthesize_presentational_hints_for_legacy_attributes` only gets `&self with no path back to the owning +// `BaseDocument`, but SVG presentation-attribute parsing needs a `UrlExtraData` to hand to Stylo's declaration +// parser. Stash it here for the duration of style resolution, mirroring the thread-local pattern already used for the +// parley layout context elsewhere in this crate. +#[cfg(feature = "svg-native")] +thread_local! { + static SVG_URL_EXTRA_DATA: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + impl crate::document::BaseDocument { pub fn resolve_stylist(&mut self, now: f64) { style::thread_state::enter(ThreadState::LAYOUT); + #[cfg(feature = "svg-native")] + SVG_URL_EXTRA_DATA.with(|cell| *cell.borrow_mut() = Some(self.url.url_extra_data())); + let guard = &self.guard; let guards = StylesheetGuards { author: &guard.read(), @@ -372,7 +385,7 @@ impl selectors::Element for BlitzNode<'_> { } fn is_html_element_in_html_document(&self) -> bool { - true // self.has_namespace(ns!(html)) + self.is_element() && self.namespace() == &markup5ever::ns!(html) } fn has_local_name(&self, local_name: &LocalName) -> bool { @@ -389,14 +402,19 @@ impl selectors::Element for BlitzNode<'_> { fn attr_matches( &self, - _ns: &NamespaceConstraint<&GenericAtomIdent>, + ns: &NamespaceConstraint<&GenericAtomIdent>, local_name: &GenericAtomIdent, operation: &AttrSelectorOperation<&AtomString>, ) -> bool { - match self.data.attr(local_name.0.clone()) { - None => false, - Some(attr_value) => operation.eval_str(attr_value), - } + self.data.attrs().is_some_and(|attrs| { + attrs.iter().any(|attr| { + let ns_matches = match ns { + NamespaceConstraint::Any => true, + NamespaceConstraint::Specific(url) => attr.name.ns == ***url, + }; + ns_matches && attr.name.local == local_name.0 && operation.eval_str(&attr.value) + }) + }) } fn match_non_ts_pseudo_class( @@ -604,7 +622,7 @@ impl<'a> TElement for BlitzNode<'a> { } fn is_html_element(&self) -> bool { - self.is_element() + self.is_element() && self.namespace() == &markup5ever::ns!(html) } // not implemented..... @@ -612,9 +630,8 @@ impl<'a> TElement for BlitzNode<'a> { false } - // need to check the namespace fn is_svg_element(&self) -> bool { - false + self.is_element() && self.namespace() == &markup5ever::ns!(svg) } fn style_attribute(&self) -> Option>> { @@ -783,10 +800,13 @@ impl<'a> TElement for BlitzNode<'a> { None } - fn get_attr(&self, attr: &style::LocalName, _ns: &style::Namespace) -> Option { - // TODO: filter by namespace + fn get_attr(&self, attr: &style::LocalName, ns: &style::Namespace) -> Option { // TODO: case-insensitive matching for HTML-ns attrs - self.attr(attr.0.clone()).map(|s| s.to_string()) + self.data + .attrs()? + .iter() + .find(|a| a.name.local == **attr && a.name.ns == **ns) + .map(|a| a.value.to_string()) } fn lang_attr(&self) -> Option { @@ -831,6 +851,29 @@ impl<'a> TElement for BlitzNode<'a> { let tag = &elem.name.local; + // SVG presentation attributes. Gated on both the feature flag and the element's namespace. + #[cfg(feature = "svg-native")] + if elem.name.ns == markup5ever::ns!(svg) { + let url_data = SVG_URL_EXTRA_DATA.with(|cell| cell.borrow().clone()); + if let Some(url_data) = url_data { + for attr in elem.attrs() { + if let Some(mut source_decl) = crate::svg::attrs::svg_presentation_hint( + &attr.name.local, + &attr.value, + &url_data, + ) { + let mut block = PropertyDeclarationBlock::new(); + block.extend(source_decl.drain(), Importance::Normal); + hints.push(ApplicableDeclarationBlock::from_declarations( + Arc::new(self.guard().wrap(block)), + CascadeLevel::new(CascadeOrigin::PresHints), + LayerOrder::root(), + )); + } + } + } + } + let mut push_style = |decl: PropertyDeclaration| { hints.push(ApplicableDeclarationBlock::from_declarations( Arc::new( diff --git a/packages/blitz-dom/src/svg/attrs.rs b/packages/blitz-dom/src/svg/attrs.rs new file mode 100644 index 0000000000..ffe382cbeb --- /dev/null +++ b/packages/blitz-dom/src/svg/attrs.rs @@ -0,0 +1,175 @@ +//! SVG presentation attributes. +//! +//! - Attributes backed by a servo-enabled longhand (the vast majority: `fill`, +//! `stroke`, `cx`, `d`, `opacity`, ...) go through the *generic* route: +//! parse the attribute name as a `PropertyId` and hand the value to Stylo's +//! own declaration parser (the same parser `style="..."` attribute values +//! go through, see `node/element.rs::set_style_property`), so we get full +//! CSS-value-syntax support (calc(), `currentColor`, units, ...) for free +//! and never hand-roll a parser that silently diverges from the cascade's. +//! - The 17 "group C" properties/shorthand (`stop-color`, `marker-start`, +//! `paint-order`, `text-anchor`, ...) are `engine = "gecko"` in this Stylo +//! build (servo has no parseable value type for them at all), so +//! `PropertyId::parse` always fails for them. Those are read as raw +//! strings directly off the DOM attribute at geometry/paint time instead +//! (see `geometry.rs`, `resolve.rs`, `render/svg.rs` in blitz-paint). + +use cssparser::ParserInput; +use markup5ever::LocalName; +use selectors::matching::QuirksMode; +use style::parser::ParserContext; +use style::properties::{PropertyDeclaration, PropertyId, SourcePropertyDeclaration}; +use style::stylesheets::{CssRuleType, Origin, UrlExtraData}; +use style_traits::ParsingMode; + +/// SVG2 presentation-attribute names that are *not* CSS properties at all +/// (geometry helpers, unit/reference attrs, `id`/`class`/`style` which are +/// handled elsewhere). Used purely to skip parser dispatch quickly; not a +/// correctness requirement since `PropertyId::parse` already rejects +/// anything that isn't a real CSS property. +fn is_plausible_presentation_attr(local: &LocalName) -> bool { + !matches!( + local.as_ref(), + "viewBox" + | "preserveAspectRatio" + | "points" + | "x1" + | "y1" + | "x2" + | "y2" + | "dx" + | "dy" + | "rotate" + | "textLength" + | "lengthAdjust" + | "gradientUnits" + | "gradientTransform" + | "spreadMethod" + | "patternUnits" + | "patternContentUnits" + | "patternTransform" + | "clipPathUnits" + | "maskUnits" + | "maskContentUnits" + | "markerUnits" + | "markerWidth" + | "markerHeight" + | "refX" + | "refY" + | "orient" + | "filterUnits" + | "primitiveUnits" + | "in" + | "in2" + | "result" + | "href" + | "xlink:href" + | "startOffset" + | "method" + | "spacing" + | "side" + | "offset" + | "id" + | "class" + | "style" + ) +} + +/// Attempt to parse `local="value"` as an SVG presentation attribute. +/// Returns the parsed declarations (a shorthand can expand to several +/// longhands) on success. Returns `None` if `local` is not a parseable +/// CSS property on this build (group-C attrs) or `value` doesn't parse +/// for that property, callers should simply skip the attribute in +/// that case, not treat it as an error. +/// +/// Attribute-name lookup is case-sensitive on the `LocalName` as-is: +/// html5ever's foreign-content adjustment already restores SVG's camelCase +/// spellings (`viewBox`, not `viewbox`), so no case-folding happens here. +pub fn svg_presentation_hint( + local: &LocalName, + value: &str, + url_extra_data: &UrlExtraData, +) -> Option { + if !is_plausible_presentation_attr(local) { + return None; + } + + // SVG presentation attributes accept unitless numbers (user units, which + // map 1:1 to CSS px) and out-of-range numeric values are clamped rather + // than rejected, unlike normal CSS length/number parsing. + let context = ParserContext::new( + Origin::Author, + url_extra_data, + Some(CssRuleType::Style), + ParsingMode::ALLOW_UNITLESS_LENGTH | ParsingMode::ALLOW_ALL_NUMERIC_VALUES, + QuirksMode::NoQuirks, + Default::default(), + None, + None, + Default::default(), + ); + + let property_id = PropertyId::parse(local.as_ref(), &context).ok()?; + + let mut source_property_declaration = SourcePropertyDeclaration::default(); + let mut input = ParserInput::new(value); + let mut parser = style::values::Parser::new(&mut input); + PropertyDeclaration::parse_into( + &mut source_property_declaration, + property_id, + &context, + &mut parser, + ) + .ok()?; + + Some(source_property_declaration) +} + +/// Read a "group C" attribute directly off the element, bypassing +/// the cascade entirely (these are not parseable CSS properties on this +/// Stylo build, so they never reach the generic route above). This means +/// they do **not** cascade, inherit, or animate. +pub fn raw_attr<'a>(attrs: &'a [crate::node::Attribute], local: &str) -> Option<&'a str> { + attrs + .iter() + .find(|a| a.name.local.as_ref() == local) + .map(|a| a.value.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn geometry_and_reference_attrs_are_not_presentation_attrs() { + for name in [ + "viewBox", + "gradientUnits", + "href", + "xlink:href", + "patternTransform", + "markerWidth", + ] { + assert!( + !is_plausible_presentation_attr(&LocalName::from(name)), + "{name} should not be treated as a presentation attribute" + ); + } + } + + #[test] + fn styling_attrs_are_plausible_presentation_attrs() { + for name in [ + "fill", + "stroke", + "cx", + "cy", + "r", + "opacity", + "d", + "transform", + ] { + assert!(is_plausible_presentation_attr(&LocalName::from(name))); + } + } +} diff --git a/packages/blitz-dom/src/svg/construct.rs b/packages/blitz-dom/src/svg/construct.rs new file mode 100644 index 0000000000..15e0c2ef52 --- /dev/null +++ b/packages/blitz-dom/src/svg/construct.rs @@ -0,0 +1,535 @@ +//! Build (and rebuild, on `CONSTRUCT_SVG` damage) the [`SvgContext`] for +//! every `` root fragment in the document. +//! +//! Runs *after* Taffy layout (`resolve::resolve_layout`), not during box +//! construction: an inline `` root is a normal CSS box whose +//! content-box size, needed to resolve `viewBox`/percentage geometry, +//! is only known once Taffy has computed `final_layout` for it. Descendants +//! never get a Taffy node at all, so there is nothing for Taffy layout +//! to do inside the fragment; this pass *is* their layout. + +use std::collections::HashMap; + +use blitz_traits::node_id::NodeId; +use kurbo::{Affine, Rect, Shape, Size}; +use parley::{FontContext, LayoutContext}; +use style::Atom; +use style::values::specified::box_::{DisplayInside, DisplayOutside}; + +use crate::BaseDocument; +use crate::layout::damage::CONSTRUCT_SVG; +use crate::node::{Attribute, SpecialElementData, TextBrush}; + +use super::attrs::raw_attr; +use super::context::{SvgContext, SvgNode, SvgNodeKind}; +use super::geometry; +use super::resolve::{self, MAX_INSTANCED_NODES}; +use super::text; +use super::viewport; + +/// Elements that establish a non-rendered reference container (UA sheet +/// `display: none`): never walked into the render-order +/// `nodes` list directly, only reachable via `id_map`. +fn is_non_rendered_container(tag: &str) -> bool { + matches!( + tag, + "defs" + | "clipPath" + | "marker" + | "mask" + | "pattern" + | "symbol" + | "filter" + | "linearGradient" + | "radialGradient" + | "stop" + | "title" + | "desc" + | "metadata" + | "style" + | "script" + ) +} + +/// Rebuild every `SvgRoot` fragment in `doc` that either has no +/// `SvgContext` yet or carries `CONSTRUCT_SVG` damage (sibling `` +/// roots are left untouched). Called once per layout pass, after Taffy +/// layout completes. +pub fn rebuild_svg_fragments(doc: &mut BaseDocument) { + let mut roots: Vec = Vec::new(); + for (id, node) in doc.nodes.iter() { + let Some(elem) = node.data.downcast_element() else { + continue; + }; + if matches!(elem.special_data, SpecialElementData::SvgRoot(_)) { + let needs_rebuild = node + .damage() + .unwrap_or(CONSTRUCT_SVG) + .contains(CONSTRUCT_SVG); + if needs_rebuild { + roots.push(id); + } + } + } + + for root in roots { + let ctx = construct_svg_fragment(doc, root); + if let Some(elem) = doc.nodes[root].element_data_mut() { + elem.special_data = SpecialElementData::SvgRoot(std::sync::Arc::new(ctx)); + } + doc.nodes[root].remove_damage(CONSTRUCT_SVG); + } +} + +/// Build a complete [`SvgContext`] for the `` root at `root` from +/// scratch (infallible -- always returns a usable, possibly-empty, +/// context; never panics on malformed author input). +pub fn construct_svg_fragment(doc: &BaseDocument, root: NodeId) -> SvgContext { + let id_map = resolve::build_id_map(doc, root); + + let layout = *doc.nodes[root].final_layout(); + let content_w = (layout.size.width + - layout.padding.left + - layout.padding.right + - layout.border.left + - layout.border.right) + .max(0.0) as f64; + let content_h = (layout.size.height + - layout.padding.top + - layout.padding.bottom + - layout.border.top + - layout.border.bottom) + .max(0.0) as f64; + let viewport = Size::new(content_w, content_h); + + let root_attrs: &[Attribute] = doc.nodes[root].attrs().unwrap_or(&[]); + let viewbox = raw_attr(root_attrs, "viewBox").and_then(viewport::parse_viewbox); + let par = raw_attr(root_attrs, "preserveAspectRatio") + .map(viewport::parse_preserve_aspect_ratio) + .unwrap_or_default(); + + // Display:none/contents on the root -> no fragment at all. + let root_display_none = doc.nodes[root] + .primary_styles() + .map(|s| { + let display = s.clone_display(); + display.outside() == DisplayOutside::None || display.inside() == DisplayInside::Contents + }) + .unwrap_or(false); + + let root_ctm = match viewbox { + // Zero-area viewBox -> nothing renders, but the fragment still + // exists (empty `nodes`) rather than being entirely absent, so + // `id_map` (used by other fragments referencing into this one, were + // that ever legal it isn't, and by devtools) stays usable. + Some(vb) if vb.width() > 0.0 && vb.height() > 0.0 => { + viewport::viewbox_to_viewport_ctm(vb, viewport, par) + } + _ => viewport::identity_ctm(), + }; + + let mut nodes = Vec::new(); + if !root_display_none && !(viewbox.is_some_and(|vb| vb.width() <= 0.0 || vb.height() <= 0.0)) { + let mut budget = MAX_INSTANCED_NODES; + let mut font_ctx_guard = doc.font_ctx.lock().unwrap(); + // `LayoutContext` isn't stored per-document as an SVG-specific field; + // a fresh one is cheap (it's a scratch buffer pool, not shape data) + // and is dropped at the end of construction. + let mut layout_ctx: LayoutContext = LayoutContext::new(); + let scale = doc.viewport.scale(); + + for &child in doc.nodes[root].children.iter() { + walk( + doc, + child, + root_ctm, + None, + &id_map, + viewport, + &mut nodes, + 0, + &mut budget, + &mut font_ctx_guard, + &mut layout_ctx, + scale, + ); + } + } + + SvgContext { + root, + viewport, + viewbox, + preserve_aspect_ratio: par, + root_ctm, + nodes, + id_map, + } +} + +#[allow(clippy::too_many_arguments)] +fn walk( + doc: &BaseDocument, + node_id: NodeId, + parent_ctm: Affine, + parent_idx: Option, + id_map: &HashMap, + viewport: Size, + nodes: &mut Vec, + use_depth: u32, + budget: &mut usize, + font_ctx: &mut FontContext, + layout_ctx: &mut LayoutContext, + scale: f32, +) { + if *budget == 0 { + return; + } + + let node: &crate::Node = &doc.nodes[node_id]; + let Some(elem) = node.data.downcast_element() else { + return; + }; + let tag = elem.name.local.as_ref(); + + if is_non_rendered_container(tag) { + return; + } + + // display:none / visibility:hidden (hidden node not painted, but + // its visible descendants still are, so `visibility` alone must not + // prune the subtree, only `display:none` does). + if let Some(style) = node.primary_styles() { + if style.clone_display().outside() == DisplayOutside::None { + return; + } + } + + *budget -= 1; + + let local_transform = raw_attr(elem.attrs(), "transform") + .map(geometry::parse_transform_list) + .unwrap_or(Affine::IDENTITY); + let ctm = parent_ctm * local_transform; + + let diag = geometry::diagonal_basis(viewport.width, viewport.height); + let attrs = elem.attrs(); + + match tag { + "g" | "a" => { + let idx = nodes.len() as u32; + nodes.push(SvgNode { + dom_id: node_id, + parent: parent_idx, + ctm, + kind: SvgNodeKind::Group, + bbox: Rect::ZERO, + }); + for &child in node.children.iter() { + walk( + doc, + child, + ctm, + Some(idx), + id_map, + viewport, + nodes, + use_depth, + budget, + font_ctx, + layout_ctx, + scale, + ); + } + } + + // A nested `` establishes its own viewport: an `x`/`y`/`width`/`height` rect in + // the parent's user space, with its own `viewBox`/ `preserveAspectRatio` scaling + // descendants into it and the percentage basis switching to this inner viewport for them. + "svg" => { + let x = geometry::parse_coord_or_zero(raw_attr(attrs, "x"), viewport.width); + let y = geometry::parse_coord_or_zero(raw_attr(attrs, "y"), viewport.height); + let w = raw_attr(attrs, "width") + .and_then(|v| geometry::parse_coord(v, viewport.width)) + .unwrap_or(viewport.width); + let h = raw_attr(attrs, "height") + .and_then(|v| geometry::parse_coord(v, viewport.height)) + .unwrap_or(viewport.height); + + let idx = nodes.len() as u32; + let viewport_ctm = ctm * Affine::translate((x, y)); + nodes.push(SvgNode { + dom_id: node_id, + parent: parent_idx, + ctm: viewport_ctm, + kind: SvgNodeKind::Group, + bbox: Rect::new(0.0, 0.0, w.max(0.0), h.max(0.0)), + }); + + // A zero-area inner viewport renders nothing. + if w <= 0.0 || h <= 0.0 { + return; + } + + let inner_viewport = Size::new(w, h); + let inner_vb = raw_attr(attrs, "viewBox").and_then(viewport::parse_viewbox); + let inner_par = raw_attr(attrs, "preserveAspectRatio") + .map(viewport::parse_preserve_aspect_ratio) + .unwrap_or_default(); + let inner_ctm = match inner_vb { + Some(vb) if vb.width() > 0.0 && vb.height() > 0.0 => { + viewport_ctm * viewport::viewbox_to_viewport_ctm(vb, inner_viewport, inner_par) + } + _ => viewport_ctm, + }; + + for &child in node.children.iter() { + walk( + doc, + child, + inner_ctm, + Some(idx), + id_map, + inner_viewport, + nodes, + use_depth, + budget, + font_ctx, + layout_ctx, + scale, + ); + } + } + + "rect" => { + let x = geometry::parse_coord_or_zero(raw_attr(attrs, "x"), viewport.width); + let y = geometry::parse_coord_or_zero(raw_attr(attrs, "y"), viewport.height); + let w = raw_attr(attrs, "width").and_then(|v| geometry::parse_coord(v, viewport.width)); + let h = + raw_attr(attrs, "height").and_then(|v| geometry::parse_coord(v, viewport.height)); + let rx = raw_attr(attrs, "rx").and_then(|v| geometry::parse_coord(v, viewport.width)); + let ry = raw_attr(attrs, "ry").and_then(|v| geometry::parse_coord(v, viewport.height)); + let (rx, ry) = geometry::resolve_rect_radii(rx, ry, w.unwrap_or(0.0), h.unwrap_or(0.0)); + if let Some(path) = + geometry::rect_path(x, y, w.unwrap_or(0.0), h.unwrap_or(0.0), rx, ry) + { + push_shape(nodes, node_id, parent_idx, ctm, path); + } + } + + "circle" => { + let cx = geometry::parse_coord_or_zero(raw_attr(attrs, "cx"), viewport.width); + let cy = geometry::parse_coord_or_zero(raw_attr(attrs, "cy"), viewport.height); + let r = geometry::parse_coord_or_zero(raw_attr(attrs, "r"), diag); + if let Some(path) = geometry::circle_path(cx, cy, r) { + push_shape(nodes, node_id, parent_idx, ctm, path); + } + } + + "ellipse" => { + let cx = geometry::parse_coord_or_zero(raw_attr(attrs, "cx"), viewport.width); + let cy = geometry::parse_coord_or_zero(raw_attr(attrs, "cy"), viewport.height); + let rx = raw_attr(attrs, "rx").and_then(|v| geometry::parse_coord(v, viewport.width)); + let ry = raw_attr(attrs, "ry").and_then(|v| geometry::parse_coord(v, viewport.height)); + // One auto radius takes the other's (already-resolved) value. + let (rx, ry) = match (rx, ry) { + (Some(rx), Some(ry)) => (rx, ry), + (Some(rx), None) => (rx, rx), + (None, Some(ry)) => (ry, ry), + (None, None) => (0.0, 0.0), + }; + if let Some(path) = geometry::ellipse_path(cx, cy, rx, ry) { + push_shape(nodes, node_id, parent_idx, ctm, path); + } + } + + "line" => { + let x1 = geometry::parse_coord_or_zero(raw_attr(attrs, "x1"), viewport.width); + let y1 = geometry::parse_coord_or_zero(raw_attr(attrs, "y1"), viewport.height); + let x2 = geometry::parse_coord_or_zero(raw_attr(attrs, "x2"), viewport.width); + let y2 = geometry::parse_coord_or_zero(raw_attr(attrs, "y2"), viewport.height); + let path = geometry::line_path(x1, y1, x2, y2); + push_shape(nodes, node_id, parent_idx, ctm, path); + } + + "polyline" => { + let pts = geometry::parse_points(raw_attr(attrs, "points").unwrap_or("")); + if let Some(path) = geometry::polyline_path(&pts) { + push_shape(nodes, node_id, parent_idx, ctm, path); + } + } + + "polygon" => { + let pts = geometry::parse_points(raw_attr(attrs, "points").unwrap_or("")); + if let Some(path) = geometry::polygon_path(&pts) { + push_shape(nodes, node_id, parent_idx, ctm, path); + } + } + + "path" => { + if let Some(path) = raw_attr(attrs, "d").and_then(geometry::path_from_d) { + push_shape(nodes, node_id, parent_idx, ctm, path); + } + } + + "text" => { + if let Some(run) = text::shape_text(doc, node_id, font_ctx, layout_ctx, scale) { + let x = geometry::parse_coord_or_zero(raw_attr(attrs, "x"), viewport.width); + let y = geometry::parse_coord_or_zero(raw_attr(attrs, "y"), viewport.height); + let text_ctm = ctm * Affine::translate((x, y)); + let full_width = run.layout.full_width() as f64; + let bbox = Rect::new(0.0, 0.0, full_width, run.layout.height() as f64); + nodes.push(SvgNode { + dom_id: node_id, + parent: parent_idx, + ctm: text_ctm, + kind: SvgNodeKind::Text(Box::new(run)), + bbox, + }); + } + } + + "foreignObject" => { + let x = geometry::parse_coord_or_zero(raw_attr(attrs, "x"), viewport.width); + let y = geometry::parse_coord_or_zero(raw_attr(attrs, "y"), viewport.height); + let w = geometry::parse_coord_or_zero(raw_attr(attrs, "width"), viewport.width); + let h = geometry::parse_coord_or_zero(raw_attr(attrs, "height"), viewport.height); + if w > 0.0 && h > 0.0 { + let fo_ctm = ctm * Affine::translate((x, y)); + nodes.push(SvgNode { + dom_id: node_id, + parent: parent_idx, + ctm: fo_ctm, + kind: SvgNodeKind::ForeignObject, + bbox: Rect::new(0.0, 0.0, w, h), + }); + } + } + + "image" => { + nodes.push(SvgNode { + dom_id: node_id, + parent: parent_idx, + ctm, + kind: SvgNodeKind::Image, + bbox: Rect::ZERO, + }); + } + + "use" => { + // Depth cap + ancestor-self cycle guard. + if use_depth >= resolve::MAX_REF_DEPTH { + return; + } + let Some(target) = resolve::resolve_href(id_map, attrs) else { + return; + }; + if target == node_id || resolve::is_ancestor_or_self(doc, target, node_id) { + return; + } + let target_tag = doc.nodes[target] + .data + .downcast_element() + .map(|e| e.name.local.clone()); + // A `` targeting ``/`` establishes a viewport of `use.width x use.height`. + let establishes_viewport = + matches!(target_tag.as_deref(), Some("symbol") | Some("svg")); + let x = geometry::parse_coord_or_zero(raw_attr(attrs, "x"), viewport.width); + let y = geometry::parse_coord_or_zero(raw_attr(attrs, "y"), viewport.height); + let use_ctm = ctm * Affine::translate((x, y)); + + let idx = nodes.len() as u32; + nodes.push(SvgNode { + dom_id: node_id, + parent: parent_idx, + ctm: use_ctm, + kind: SvgNodeKind::Use { target }, + bbox: Rect::ZERO, + }); + + if establishes_viewport { + let use_w = raw_attr(attrs, "width") + .and_then(|v| geometry::parse_coord(v, viewport.width)) + .unwrap_or(viewport.width); + let use_h = raw_attr(attrs, "height") + .and_then(|v| geometry::parse_coord(v, viewport.height)) + .unwrap_or(viewport.height); + let target_attrs = doc.nodes[target].attrs().unwrap_or(&[]); + let inner_vb = raw_attr(target_attrs, "viewBox").and_then(viewport::parse_viewbox); + let inner_par = raw_attr(target_attrs, "preserveAspectRatio") + .map(viewport::parse_preserve_aspect_ratio) + .unwrap_or_default(); + let inner_ctm = match inner_vb { + Some(vb) if vb.width() > 0.0 && vb.height() > 0.0 => { + use_ctm + * viewport::viewbox_to_viewport_ctm( + vb, + Size::new(use_w, use_h), + inner_par, + ) + } + _ => use_ctm, + }; + for &child in doc.nodes[target].children.iter() { + walk( + doc, + child, + inner_ctm, + Some(idx), + id_map, + viewport, + nodes, + use_depth + 1, + budget, + font_ctx, + layout_ctx, + scale, + ); + } + } else { + walk( + doc, + target, + use_ctm, + Some(idx), + id_map, + viewport, + nodes, + use_depth + 1, + budget, + font_ctx, + layout_ctx, + scale, + ); + } + } + + // Unrecognized element (including ``, ``, custom elements): degrade gracefully by treating + // it as a transparent group rather than dropping its subtree. + _ => { + for &child in node.children.iter() { + walk( + doc, child, ctm, parent_idx, id_map, viewport, nodes, use_depth, budget, + font_ctx, layout_ctx, scale, + ); + } + } + } +} + +fn push_shape( + nodes: &mut Vec, + dom_id: NodeId, + parent: Option, + ctm: Affine, + path: kurbo::BezPath, +) { + let bbox = path.bounding_box(); + nodes.push(SvgNode { + dom_id, + parent, + ctm, + kind: SvgNodeKind::Shape(path), + bbox, + }); +} diff --git a/packages/blitz-dom/src/svg/context.rs b/packages/blitz-dom/src/svg/context.rs new file mode 100644 index 0000000000..c4fce4a44a --- /dev/null +++ b/packages/blitz-dom/src/svg/context.rs @@ -0,0 +1,110 @@ +//! Core geometry-tree types for first-party inline SVG rendering. +//! +//! An [`SvgContext`] is built once per root `` fragment (rebuilt wholesale +//! on `CONSTRUCT_SVG` damage, see `layout::damage`) and holds a flat, +//! render-order list of [`SvgNode`]s with precomputed CTMs. Painting is then a +//! linear scan with no tree recursion or matrix chaining. + +use std::collections::HashMap; + +use blitz_traits::node_id::NodeId; +use kurbo::{Affine, BezPath, Rect, Size}; +use style::Atom; + +use super::text::TextAnchor; +use crate::node::TextBrush; + +/// A shaped ``/`` run plus its anchor alignment. +pub struct TextRun { + pub layout: parley::Layout, + pub anchor: TextAnchor, +} + +/// `preserveAspectRatio` alignment keyword. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + None, + XMinYMin, + XMidYMin, + XMaxYMin, + XMinYMid, + XMidYMid, + XMaxYMid, + XMinYMax, + XMidYMax, + XMaxYMax, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeetOrSlice { + Meet, + Slice, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PreserveAspectRatio { + pub align: Align, + pub meet_or_slice: MeetOrSlice, +} + +impl Default for PreserveAspectRatio { + fn default() -> Self { + Self { + align: Align::XMidYMid, + meet_or_slice: MeetOrSlice::Meet, + } + } +} + +/// A fully-resolved inline-SVG fragment, rooted at one `` element that establishes a CSS box. +pub struct SvgContext { + /// DOM id of the root `` element. + pub root: NodeId, + /// CSS content-box size of the root ``, in CSS px. + pub viewport: Size, + /// Parsed `viewBox` attribute, if present. + pub viewbox: Option, + pub preserve_aspect_ratio: PreserveAspectRatio, + pub root_ctm: Affine, + /// Flattened render-order node list. Excludes elements inside non-rendered containers, + /// those are reachable only via `id_map`. + pub nodes: Vec, + /// Covers every id in the fragment, including inside `display:none` containers + /// which never get an [`SvgNode`] of their own (V24). + pub id_map: HashMap, +} + +pub struct SvgNode { + pub dom_id: NodeId, + pub parent: Option, + /// This node's own user space -> viewport space. + pub ctm: Affine, + pub kind: SvgNodeKind, + /// Object bounding box (fill geometry only, no stroke), in this node's own user space. + /// Used as the reference box for `objectBoundingBox` paint-server units, `transform-box`, + /// and geometry percentages that resolve against it. + pub bbox: Rect, +} + +pub enum SvgNodeKind { + /// ``, ``, `` (nested viewport establisher), `` + /// instance root, or any container with no geometry of its own. + Group, + /// A filled/stroked shape: ``, ``, ``, ``, + /// ``, ``, ``. Path is in the node's own user + /// space. + Shape(BezPath), + /// `` / standalone `` run, already shaped. + Text(Box), + /// `` with a resolved raster source (`None` while the fetch is + /// still pending. + Image, + /// ``: re-enters normal HTML layout/paint. Carries + /// the DOM id of the `` element itself so paint can + /// re-enter `render_element`. + ForeignObject, + /// `` instance root. The target subtree is shadow-expanded into further + /// [`SvgNode`]s appended to `nodes` with `parent` pointing back at this + /// node, so painting remains a flat linear scan. + Use { target: NodeId }, +} diff --git a/packages/blitz-dom/src/svg/geometry.rs b/packages/blitz-dom/src/svg/geometry.rs new file mode 100644 index 0000000000..ad4446deb1 --- /dev/null +++ b/packages/blitz-dom/src/svg/geometry.rs @@ -0,0 +1,341 @@ +//! Basic-shape element -> [`BezPath`] conversion, and the `points` attribute parser. +//! +//! Pure geometry: callers are responsible for pulling resolved `f64` values out of +//! computed style / raw attributes first, +//! so every function here is unit-testable without any Stylo dependency. + +use kurbo::{BezPath, Circle, Ellipse, Point, Shape}; + +/// Tolerance used when flattening kurbo's analytic shapes into cubic Beziers. +/// Small enough to be visually exact at any reasonable zoom; not user-configurable. +const TOLERANCE: f64 = 0.1; + +/// Bezier offset ratio for approximating a quarter circle/ ellipse arc with a single cubic curve. +const KAPPA: f64 = 0.5522847498307936; + +pub fn resolve_rect_radii(rx: Option, ry: Option, width: f64, height: f64) -> (f64, f64) { + let (rx, ry) = match (rx, ry) { + (None, None) => (0.0, 0.0), + (Some(rx), None) => (rx, rx), + (None, Some(ry)) => (ry, ry), + (Some(rx), Some(ry)) => (rx, ry), + }; + (rx.max(0.0).min(width / 2.0), ry.max(0.0).min(height / 2.0)) +} + +/// `` geometry. `width`/`height` <= 0 is the "auto -> 0 -> not rendered"; +/// the caller checks that before calling this. +pub fn rect_path(x: f64, y: f64, width: f64, height: f64, rx: f64, ry: f64) -> Option { + if width <= 0.0 || height <= 0.0 { + return None; + } + + if rx <= 0.0 || ry <= 0.0 { + let mut p = BezPath::new(); + p.move_to((x, y)); + p.line_to((x + width, y)); + p.line_to((x + width, y + height)); + p.line_to((x, y + height)); + p.close_path(); + return Some(p); + } + + let ox = rx * KAPPA; + let oy = ry * KAPPA; + let mut p = BezPath::new(); + p.move_to((x + rx, y)); + p.line_to((x + width - rx, y)); + p.curve_to( + (x + width - rx + ox, y), + (x + width, y + ry - oy), + (x + width, y + ry), + ); + p.line_to((x + width, y + height - ry)); + p.curve_to( + (x + width, y + height - ry + oy), + (x + width - rx + ox, y + height), + (x + width - rx, y + height), + ); + p.line_to((x + rx, y + height)); + p.curve_to( + (x + rx - ox, y + height), + (x, y + height - ry + oy), + (x, y + height - ry), + ); + p.line_to((x, y + ry)); + p.curve_to((x, y + ry - oy), (x + rx - ox, y), (x + rx, y)); + p.close_path(); + Some(p) +} + +/// ``. `r <= 0` -> not rendered. +pub fn circle_path(cx: f64, cy: f64, r: f64) -> Option { + if r <= 0.0 { + return None; + } + Some(Circle::new(Point::new(cx, cy), r).into_path(TOLERANCE)) +} + +/// ``. `rx <= 0 || ry <= 0` -> not rendered. Auto-radius resolution is the caller's +/// responsibility since it needs the *other* radius already resolved. +pub fn ellipse_path(cx: f64, cy: f64, rx: f64, ry: f64) -> Option { + if rx <= 0.0 || ry <= 0.0 { + return None; + } + Some(Ellipse::new(Point::new(cx, cy), (rx, ry), 0.0).into_path(TOLERANCE)) +} + +/// ``. Never filled; stroke-only. Always returns a two-point open subpath regardless of coordinates. +pub fn line_path(x1: f64, y1: f64, x2: f64, y2: f64) -> BezPath { + let mut p = BezPath::new(); + p.move_to((x1, y1)); + p.line_to((x2, y2)); + p +} + +/// ``: open path through `points`. `None` if fewer than 2 points remain after `parse_points` +/// odd-trailing-coordinate handling. +pub fn polyline_path(points: &[(f64, f64)]) -> Option { + if points.len() < 2 { + return None; + } + let mut p = BezPath::new(); + p.move_to(points[0]); + for &pt in &points[1..] { + p.line_to(pt); + } + Some(p) +} + +/// ``: same as `polyline_path` but auto-closed. +pub fn polygon_path(points: &[(f64, f64)]) -> Option { + let mut p = polyline_path(points)?; + p.close_path(); + Some(p) +} + +/// Parse a `points="x1,y1 x2,y2 ..."` attribute value into coordinate pairs. +/// An odd trailing number (malformed input) is dropped and the well-formed prefix is kept. +pub fn parse_points(s: &str) -> Vec<(f64, f64)> { + let nums = super::viewport::parse_number_list(s); + nums.as_chunks::<2>() + .0 + .iter() + .map(|&[x, y]| (x, y)) + .collect() +} + +/// ``. Reuses kurbo's own SVG path-data grammar parser (`BezPath::from_svg`) rather than +/// re-implementing or adapting the CSS-`path()`-shapes converter in `blitz-paint::render::clip_path`, which +/// takes a different (already-tokenized, Stylo-typed) input and exists for CSS `clip-path: +/// path(...)` / `shape(...)`, not SVG's `d` grammar. +/// +/// Malformed `d` values degrade to "not rendered" rather than a partial path, since a syntax error partway +/// through `d` makes the rest of the string unparseable position-wise. +pub fn path_from_d(d: &str) -> Option { + if d.trim().is_empty() || d.trim() == "none" { + return None; + } + BezPath::from_svg(d).ok() +} + +/// SVG percentage resolution basis: x-axis lengths resolve against viewport width, y-axis against height, +/// and everything else (radii, stroke-width, ...) against the normalized diagonal `sqrt(w^2+h^2) / sqrt(2)`. +pub fn diagonal_basis(viewport_width: f64, viewport_height: f64) -> f64 { + ((viewport_width * viewport_width + viewport_height * viewport_height) / 2.0).sqrt() +} + +/// Parse an SVG length/coordinate/percentage attribute value: a bare number is user units. +pub fn parse_coord(value: &str, basis: f64) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + if let Some(pct) = value.strip_suffix('%') { + return pct.trim().parse::().ok().map(|p| p / 100.0 * basis); + } + let numeric_len = value + .char_indices() + .take_while(|(i, c)| { + c.is_ascii_digit() + || *c == '.' + || (*c == '-' && *i == 0) + || (*c == '+' && *i == 0) + || *c == 'e' + || *c == 'E' + }) + .count(); + value[..numeric_len].parse::().ok() +} + +/// `parse_coord` with a `0.0` default for missing/unparseable attributes, +/// for every coordinate attribute covered here +/// (`x`, `y`, `x1`, `y1`, `x2`, `y2`, `dx`, `dy`; `cx`/`cy` also default to `0`). +pub fn parse_coord_or_zero(value: Option<&str>, basis: f64) -> f64 { + value.and_then(|v| parse_coord(v, basis)).unwrap_or(0.0) +} + +/// Parse an SVG `transform` attribute value (the `transform-list` grammar: +/// `translate()`, `scale()`, `rotate()`, `skewX()`, `skewY()`, `matrix()`, +/// space/comma separated, composed left-to-right) into a single [`kurbo::Affine`]. +pub fn parse_transform_list(value: &str) -> kurbo::Affine { + let mut result = kurbo::Affine::IDENTITY; + let mut rest = value.trim(); + while !rest.is_empty() { + let Some(open) = rest.find('(') else { break }; + let name = rest[..open].trim(); + let Some(close) = rest[open..].find(')') else { + break; + }; + let args_str = &rest[open + 1..open + close]; + let args = super::viewport::parse_number_list(args_str); + rest = rest[open + close + 1..].trim_start_matches([' ', ',']); + + let m = match (name, args.as_slice()) { + ("translate", [x]) => Some(kurbo::Affine::translate((*x, 0.0))), + ("translate", [x, y]) => Some(kurbo::Affine::translate((*x, *y))), + ("scale", [s]) => Some(kurbo::Affine::scale(*s)), + ("scale", [sx, sy]) => Some(kurbo::Affine::scale_non_uniform(*sx, *sy)), + ("rotate", [deg]) => Some(kurbo::Affine::rotate(deg.to_radians())), + ("rotate", [deg, cx, cy]) => Some( + kurbo::Affine::translate((*cx, *cy)) + * kurbo::Affine::rotate(deg.to_radians()) + * kurbo::Affine::translate((-*cx, -*cy)), + ), + ("skewX", [deg]) => Some(kurbo::Affine::skew(deg.to_radians().tan(), 0.0)), + ("skewY", [deg]) => Some(kurbo::Affine::skew(0.0, deg.to_radians().tan())), + ("matrix", [a, b, c, d, e, f]) => Some(kurbo::Affine::new([*a, *b, *c, *d, *e, *f])), + _ => None, + }; + if let Some(m) = m { + result *= m; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rect_radii_both_auto_are_zero() { + assert_eq!(resolve_rect_radii(None, None, 100.0, 50.0), (0.0, 0.0)); + } + + #[test] + fn rect_radii_one_auto_takes_the_other() { + assert_eq!( + resolve_rect_radii(Some(10.0), None, 100.0, 50.0), + (10.0, 10.0) + ); + assert_eq!(resolve_rect_radii(None, Some(8.0), 100.0, 50.0), (8.0, 8.0)); + } + + #[test] + fn rect_radii_clamp_to_half_axis() { + assert_eq!( + resolve_rect_radii(Some(1000.0), Some(1000.0), 100.0, 50.0), + (50.0, 25.0) + ); + } + + #[test] + fn zero_or_negative_rect_dims_are_not_rendered() { + assert!(rect_path(0.0, 0.0, 0.0, 10.0, 0.0, 0.0).is_none()); + assert!(rect_path(0.0, 0.0, 10.0, -1.0, 0.0, 0.0).is_none()); + } + + #[test] + fn zero_radius_circle_is_not_rendered() { + assert!(circle_path(0.0, 0.0, 0.0).is_none()); + assert!(circle_path(0.0, 0.0, -5.0).is_none()); + assert!(circle_path(0.0, 0.0, 5.0).is_some()); + } + + #[test] + fn degenerate_ellipse_is_not_rendered() { + assert!(ellipse_path(0.0, 0.0, 0.0, 5.0).is_none()); + assert!(ellipse_path(0.0, 0.0, 5.0, 0.0).is_none()); + } + + #[test] + fn parse_points_drops_odd_trailing_coordinate() { + assert_eq!( + parse_points("0,0 10,10 20,20 5"), + vec![(0.0, 0.0), (10.0, 10.0), (20.0, 20.0)] + ); + } + + #[test] + fn parse_points_handles_whitespace_separated() { + assert_eq!(parse_points("0 0 10 10"), vec![(0.0, 0.0), (10.0, 10.0)]); + } + + #[test] + fn polyline_is_open_polygon_is_closed() { + let pts = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]; + let polyline = polyline_path(&pts).unwrap(); + let polygon = polygon_path(&pts).unwrap(); + assert!(!matches!( + polyline.elements().last(), + Some(kurbo::PathEl::ClosePath) + )); + assert!(matches!( + polygon.elements().last(), + Some(kurbo::PathEl::ClosePath) + )); + } + + #[test] + fn too_few_points_yields_no_path() { + assert!(polyline_path(&[(0.0, 0.0)]).is_none()); + } + + #[test] + fn parse_coord_handles_bare_numbers_and_percent() { + assert_eq!(parse_coord("10", 200.0), Some(10.0)); + assert_eq!(parse_coord("-5.5", 200.0), Some(-5.5)); + assert_eq!(parse_coord("50%", 200.0), Some(100.0)); + assert_eq!(parse_coord("", 200.0), None); + } + + #[test] + fn parse_coord_or_zero_defaults_missing_to_zero() { + assert_eq!(parse_coord_or_zero(None, 200.0), 0.0); + assert_eq!(parse_coord_or_zero(Some("bogus"), 200.0), 0.0); + } + + #[test] + fn transform_list_composes_translate_and_scale() { + let t = parse_transform_list("translate(10, 20) scale(2)"); + let p = t * Point::new(0.0, 0.0); + assert_eq!(p, Point::new(10.0, 20.0)); + let p2 = t * Point::new(1.0, 1.0); + assert_eq!(p2, Point::new(12.0, 22.0)); + } + + #[test] + fn transform_list_skips_unknown_function() { + let t = parse_transform_list("bogus(1,2,3) translate(5,5)"); + assert_eq!(t * Point::new(0.0, 0.0), Point::new(5.0, 5.0)); + } + + #[test] + fn path_from_d_parses_simple_path() { + let p = path_from_d("M0,0 L10,0 L10,10 Z").unwrap(); + assert!(p.elements().len() >= 3); + } + + #[test] + fn path_from_d_rejects_empty_and_none() { + assert!(path_from_d("").is_none()); + assert!(path_from_d("none").is_none()); + } + + #[test] + fn diagonal_basis_matches_svg_normalized_diagonal_formula() { + // sqrt((w^2+h^2)/2), e.g. a square viewport's diagonal basis equals its side length. + assert!((diagonal_basis(100.0, 100.0) - 100.0).abs() < 1e-9); + } +} diff --git a/packages/blitz-dom/src/svg/hit_test.rs b/packages/blitz-dom/src/svg/hit_test.rs new file mode 100644 index 0000000000..1700e03bdf --- /dev/null +++ b/packages/blitz-dom/src/svg/hit_test.rs @@ -0,0 +1,119 @@ +//! SVG hit testing. +//! +//! `pointer-events` on this Stylo build's servo engine has only `Auto | None`. + +use kurbo::{Point, Shape, Stroke}; + +use super::context::{SvgContext, SvgNodeKind}; +use style::properties::generated::longhands::visibility::computed_value::T as StyloVisibility; + +/// Whether an SVG paint value counts as "painted" for hit testing purposes. +fn is_painted(raw_paint_attr: Option<&str>, default_none: bool) -> bool { + match raw_paint_attr.map(str::trim) { + Some("none") => false, + Some(_) => true, + None => !default_none, + } +} + +/// Hit-test `point` against `ctx`, walking nodes in reverse render order. +/// Returns the DOM id of the topmost hit shape, if any. +pub fn hit_test( + tree_node: &crate::Node, + ctx: &SvgContext, + point: Point, +) -> Option { + for node in ctx.nodes.iter().rev() { + let SvgNodeKind::Shape(path) = &node.kind else { + continue; + }; + + let Some(inverse) = try_invert(node.ctm) else { + continue; + }; + let local_point = inverse * point; + + let dom_node = tree_node.with(node.dom_id); + let attrs = dom_node.attrs().unwrap_or(&[]); + + if let Some(style) = dom_node.primary_styles() { + if style.get_inherited_box().visibility != StyloVisibility::Visible { + continue; + } + } + + if super::attrs::raw_attr(attrs, "pointer-events") == Some("none") { + continue; + } + + let fill_painted = is_painted(super::attrs::raw_attr(attrs, "fill"), false); + let stroke_painted = is_painted(super::attrs::raw_attr(attrs, "stroke"), true); + + if fill_painted && path.contains(local_point) { + return Some(node.dom_id); + } + + if stroke_painted { + let stroke_width = super::attrs::raw_attr(attrs, "stroke-width") + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(1.0); + if stroke_width > 0.0 { + let stroke = Stroke::new(stroke_width); + let outline = kurbo::stroke( + path.path_elements(0.1), + &stroke, + &kurbo::StrokeOpts::default(), + 0.1, + ); + if outline.contains(local_point) { + return Some(node.dom_id); + } + } + } + } + None +} + +fn try_invert(affine: kurbo::Affine) -> Option { + // kurbo::Affine::inverse() is defined for all (including singular) + // matrices, producing non-finite output for singular ones; guard + // explicitly rather than hit-testing against garbage coordinates. + if affine.determinant().abs() < 1e-12 { + return None; + } + Some(affine.inverse()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unset_fill_defaults_to_painted() { + assert!(is_painted(None, false)); + } + + #[test] + fn unset_stroke_defaults_to_not_painted() { + assert!(!is_painted(None, true)); + } + + #[test] + fn explicit_none_is_never_painted() { + assert!(!is_painted(Some("none"), false)); + assert!(!is_painted(Some("none"), true)); + } + + #[test] + fn explicit_color_is_painted() { + assert!(is_painted(Some("red"), true)); + } + + #[test] + fn singular_matrix_is_not_invertible_for_hit_testing() { + let singular = kurbo::Affine::new([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + assert!(try_invert(singular).is_none()); + let identity = kurbo::Affine::IDENTITY; + assert!(try_invert(identity).is_some()); + } +} diff --git a/packages/blitz-dom/src/svg/mod.rs b/packages/blitz-dom/src/svg/mod.rs new file mode 100644 index 0000000000..560605c993 --- /dev/null +++ b/packages/blitz-dom/src/svg/mod.rs @@ -0,0 +1,15 @@ +//! First-party inline SVG rendering, behind the `svg-native` feature flag + +pub mod attrs; +pub mod construct; +pub mod context; +pub mod geometry; +pub mod hit_test; +pub mod resolve; +pub mod text; +pub mod viewport; + +pub use construct::{construct_svg_fragment, rebuild_svg_fragments}; +pub use context::{ + Align, MeetOrSlice, PreserveAspectRatio, SvgContext, SvgNode, SvgNodeKind, TextRun, +}; diff --git a/packages/blitz-dom/src/svg/resolve.rs b/packages/blitz-dom/src/svg/resolve.rs new file mode 100644 index 0000000000..2b72328555 --- /dev/null +++ b/packages/blitz-dom/src/svg/resolve.rs @@ -0,0 +1,127 @@ +//! `id_map` construction and reference-chain resolution shared by ``, gradient `href` +//! inheritance, and paint-server/clip/mask/filter `url(#id)` lookups. + +use std::collections::HashMap; + +use blitz_traits::node_id::NodeId; +use style::Atom; + +use crate::BaseDocument; + +use super::attrs::raw_attr; + +/// Maximum reference-chain depth for ``, gradient `href`, and clip/mask/filter nesting. +/// Applied uniformly as a single backstop against hand-authored (or hostile) cyclic SVG +/// rather than one constant per feature. +pub const MAX_REF_DEPTH: u32 = 16; + +/// Total number of instanced nodes a `` expansion may produce across the whole fragment +/// before construction gives up and renders the prefix. +pub const MAX_INSTANCED_NODES: usize = 100_000; + +/// Build the `id` -> `NodeId` map for every element in the fragment, by walking full +/// DOM children, before the render walk. +pub fn build_id_map(doc: &BaseDocument, root: NodeId) -> HashMap { + let mut map = HashMap::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + let node = &doc.nodes[id]; + if let Some(elem) = node.data.downcast_element() { + if let Some(id_atom) = elem.id.as_ref() { + map.entry(id_atom.clone()).or_insert(id); + } + } + stack.extend(node.children.iter().copied()); + } + map +} + +/// Resolve an SVG `href`/`xlink:href` attribute (read off `attrs`) to a target `NodeId` within this +/// fragment. Prefers the namespace-less `href` SVG2), falling back to `xlink:href`. +/// Only local fragment references (`#id`) are resolved, external-document references +/// (`other.svg#id`) are resolve to `None`. +pub fn resolve_href( + id_map: &HashMap, + attrs: &[crate::node::Attribute], +) -> Option { + let href = raw_attr(attrs, "href").or_else(|| raw_attr(attrs, "xlink:href"))?; + let id = href.trim().strip_prefix('#')?; + if id.is_empty() { + return None; + } + id_map.get(&Atom::from(id)).copied() +} + +/// Walk a same-kind reference chain starting at `start`, following `next` until it returns `None`,a +/// node repeats, or [`MAX_REF_DEPTH`] links have been followed. Returns the chain in traversal order. +pub fn resolve_ref_chain( + start: NodeId, + mut next: impl FnMut(NodeId) -> Option, +) -> Vec { + let mut chain = vec![start]; + let mut cur = start; + for _ in 0..MAX_REF_DEPTH { + let Some(nxt) = next(cur) else { break }; + if chain.contains(&nxt) { + break; + } + chain.push(nxt); + cur = nxt; + } + chain +} + +/// Whether `candidate` is `target` or a DOM ancestor of `target`, used by the `` cycle guard. +/// Walks up the DOM `parent` chain, capped at a generous hop count as a backstop against +/// malformed/cyclic parent chains (which should not occur, but must never hang here). +pub fn is_ancestor_or_self(doc: &BaseDocument, candidate: NodeId, target: NodeId) -> bool { + let mut cur = Some(target); + for _ in 0..4096 { + match cur { + Some(id) if id == candidate => return true, + Some(id) => cur = doc.nodes[id].parent, + None => return false, + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ref_chain_stops_at_cycle() { + // 0 -> 1 -> 2 -> 0 (cycle) + let next = |n: NodeId| -> Option { + match n.as_u64() { + 0 => Some(NodeId::from_u64(1)), + 1 => Some(NodeId::from_u64(2)), + 2 => Some(NodeId::from_u64(0)), + _ => None, + } + }; + let chain = resolve_ref_chain(NodeId::from_u64(0), next); + assert_eq!(chain.len(), 3); + } + + #[test] + fn ref_chain_stops_at_max_depth() { + let next = |n: NodeId| -> Option { Some(NodeId::from_u64(n.as_u64() + 1)) }; + let chain = resolve_ref_chain(NodeId::from_u64(0), next); + assert_eq!(chain.len() as u32, MAX_REF_DEPTH + 1); + } + + #[test] + fn ref_chain_stops_when_exhausted() { + let next = |n: NodeId| -> Option { + if n.as_u64() == 0 { + Some(NodeId::from_u64(1)) + } else { + None + } + }; + let chain = resolve_ref_chain(NodeId::from_u64(0), next); + assert_eq!(chain.len(), 2); + } +} diff --git a/packages/blitz-dom/src/svg/text.rs b/packages/blitz-dom/src/svg/text.rs new file mode 100644 index 0000000000..4086fccd83 --- /dev/null +++ b/packages/blitz-dom/src/svg/text.rs @@ -0,0 +1,141 @@ +//! `` / `` shaping via parley. +//! +//! Reuses the same `TreeBuilder` + `stylo_to_parley::style` machinery the normal HTML inline-layout +//! path uses, just driven over an SVG ``/`` subtree instead of an HTML inline formatting context. + +use blitz_traits::node_id::NodeId; +use parley::{FontContext, LayoutContext, TreeBuilder}; + +use crate::BaseDocument; +use crate::node::{NodeData, TextBrush}; + +use super::attrs::raw_attr; +use super::context::TextRun; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TextAnchor { + #[default] + Start, + Middle, + End, +} + +fn parse_text_anchor(attrs: &[crate::node::Attribute]) -> TextAnchor { + match raw_attr(attrs, "text-anchor") { + Some("middle") => TextAnchor::Middle, + Some("end") => TextAnchor::End, + _ => TextAnchor::Start, + } +} + +/// Shape a `` element into a parley `Layout`. `scale` is the document's device scale factor. Returns +/// `None` if the subtree has no styled root or no non-whitespace text. +pub fn shape_text( + doc: &BaseDocument, + text_root: NodeId, + font_ctx: &mut FontContext, + layout_ctx: &mut LayoutContext, + scale: f32, +) -> Option { + let root_node = &doc.nodes[text_root]; + let root_style = root_node.primary_styles()?; + let parley_style = crate::stylo_to_parley::style(text_root, &root_style); + + let mut builder = layout_ctx.tree_builder(font_ctx, scale, true, &parley_style); + builder.set_white_space_mode(parley::WhiteSpaceCollapse::Collapse); + + let mut has_text = false; + push_children(doc, text_root, &mut builder, &mut has_text); + if !has_text { + return None; + } + + let mut layout = parley::Layout::default(); + builder.build_into(&mut layout); + layout.break_all_lines(None); + + let anchor = parse_text_anchor(root_node.attrs().unwrap_or(&[])); + Some(TextRun { layout, anchor }) +} + +fn push_children( + doc: &BaseDocument, + node_id: NodeId, + builder: &mut TreeBuilder, + has_text: &mut bool, +) { + let node = &doc.nodes[node_id]; + for &child_id in node.children.iter() { + let child = &doc.nodes[child_id]; + match &child.data { + NodeData::Text(text_data) => { + let collapsed = collapse_whitespace(&text_data.content); + if !collapsed.is_empty() { + builder.push_text(&collapsed); + *has_text = true; + } + } + NodeData::Element(_) => { + if let Some(style) = child.primary_styles() { + let child_style = crate::stylo_to_parley::style(child_id, &style); + builder.push_style_span(child_style); + push_children(doc, child_id, builder, has_text); + builder.pop_style_span(); + } else { + push_children(doc, child_id, builder, has_text); + } + } + _ => {} + } + } +} + +/// SVG default whitespace handling (`xml:space="default"`): collapse runs of ASCII whitespace +/// to a single space. Leading/trailing trimming is left to parley's own line-breaking, +/// so only internal collapsing happens here. +fn collapse_whitespace(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut last_was_space = false; + for c in s.chars() { + if c.is_whitespace() { + if !last_was_space { + out.push(' '); + } + last_was_space = true; + } else { + out.push(c); + last_was_space = false; + } + } + out +} + +/// Horizontal shift to apply to a shaped run's origin so it lands at the SVG `x`. +pub fn anchor_shift(anchor: TextAnchor, full_width: f32) -> f32 { + match anchor { + TextAnchor::Start => 0.0, + TextAnchor::Middle => -full_width / 2.0, + TextAnchor::End => -full_width, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collapses_internal_whitespace_runs() { + assert_eq!(collapse_whitespace("a b\n\tc"), "a b c"); + } + + #[test] + fn anchor_shift_start_is_zero() { + assert_eq!(anchor_shift(TextAnchor::Start, 100.0), 0.0); + } + + #[test] + fn anchor_shift_middle_and_end() { + assert_eq!(anchor_shift(TextAnchor::Middle, 100.0), -50.0); + assert_eq!(anchor_shift(TextAnchor::End, 100.0), -100.0); + } +} diff --git a/packages/blitz-dom/src/svg/viewport.rs b/packages/blitz-dom/src/svg/viewport.rs new file mode 100644 index 0000000000..d7f10bbb3c --- /dev/null +++ b/packages/blitz-dom/src/svg/viewport.rs @@ -0,0 +1,190 @@ +//! `viewBox` + `preserveAspectRatio` parsing and the viewBox->viewport [`Affine`] they produce. + +use kurbo::{Affine, Rect, Size}; + +use super::context::{Align, MeetOrSlice, PreserveAspectRatio}; + +/// A minimal SVG number-list tokenizer: splits on ASCII whitespace and/or +/// commas (SVG's "wsp* comma? wsp*" separator grammar), parsing each token +/// as an `f64`. Shared by `viewBox` and (in `geometry.rs`) `points`. +pub(super) fn parse_number_list(s: &str) -> Vec { + s.split(|c: char| c.is_ascii_whitespace() || c == ',') + .filter(|tok| !tok.is_empty()) + .filter_map(|tok| tok.parse::().ok()) + .collect() +} + +/// Parse a `viewBox="min-x min-y width height"` attribute value. Returns `None` if malformed, +/// the caller then treats the `` as if it had no `viewBox` at all, which is the closest +/// interoperable fallback. +pub fn parse_viewbox(value: &str) -> Option { + let nums = parse_number_list(value); + if nums.len() != 4 { + return None; + } + let (min_x, min_y, w, h) = (nums[0], nums[1], nums[2], nums[3]); + Some(Rect::new(min_x, min_y, min_x + w, min_y + h)) +} + +/// Parse a `preserveAspectRatio="[defer] []"` value. `defer` is accepted +/// and ignored. Falls back to the SVG2 default (`xMidYMid meet`) on any unrecognized token, +/// error-recovery for presentation attributes. +pub fn parse_preserve_aspect_ratio(value: &str) -> PreserveAspectRatio { + let mut tokens = value.split_ascii_whitespace().peekable(); + if tokens.peek() == Some(&"defer") { + tokens.next(); + } + + let align = match tokens.next() { + Some("none") => Align::None, + Some("xMinYMin") => Align::XMinYMin, + Some("xMidYMin") => Align::XMidYMin, + Some("xMaxYMin") => Align::XMaxYMin, + Some("xMinYMid") => Align::XMinYMid, + Some("xMidYMid") | None => Align::XMidYMid, + Some("xMaxYMid") => Align::XMaxYMid, + Some("xMinYMax") => Align::XMinYMax, + Some("xMidYMax") => Align::XMidYMax, + Some("xMaxYMax") => Align::XMaxYMax, + Some(_) => Align::XMidYMid, + }; + + let meet_or_slice = match tokens.next() { + Some("slice") => MeetOrSlice::Slice, + _ => MeetOrSlice::Meet, + }; + + PreserveAspectRatio { + align, + meet_or_slice, + } +} + +/// Compute the `viewBox` coordinate space -> viewport (CSS box) space transform. +/// `viewbox.width == 0 || viewbox.height == 0` is the caller's responsibility to check first. +pub fn viewbox_to_viewport_ctm(viewbox: Rect, viewport: Size, par: PreserveAspectRatio) -> Affine { + let vb_w = viewbox.width(); + let vb_h = viewbox.height(); + + let (sx, sy) = if par.align == Align::None { + (viewport.width / vb_w, viewport.height / vb_h) + } else { + let sx = viewport.width / vb_w; + let sy = viewport.height / vb_h; + let s = match par.meet_or_slice { + MeetOrSlice::Meet => sx.min(sy), + MeetOrSlice::Slice => sx.max(sy), + }; + (s, s) + }; + + let (align_x, align_y) = align_fractions(par.align); + let tx = -viewbox.x0 * sx + align_x * (viewport.width - vb_w * sx); + let ty = -viewbox.y0 * sy + align_y * (viewport.height - vb_h * sy); + + Affine::new([sx, 0.0, 0.0, sy, tx, ty]) +} + +/// `viewBox` absent -> identity CTM, viewport equals the CSS box directly. +pub fn identity_ctm() -> Affine { + Affine::IDENTITY +} + +fn align_fractions(align: Align) -> (f64, f64) { + let x = match align { + Align::None | Align::XMinYMin | Align::XMinYMid | Align::XMinYMax => 0.0, + Align::XMidYMin | Align::XMidYMid | Align::XMidYMax => 0.5, + Align::XMaxYMin | Align::XMaxYMid | Align::XMaxYMax => 1.0, + }; + let y = match align { + Align::None | Align::XMinYMin | Align::XMidYMin | Align::XMaxYMin => 0.0, + Align::XMinYMid | Align::XMidYMid | Align::XMaxYMid => 0.5, + Align::XMinYMax | Align::XMidYMax | Align::XMaxYMax => 1.0, + }; + (x, y) +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Point; + + #[test] + fn parses_viewbox_with_commas_and_whitespace() { + assert_eq!( + parse_viewbox("0 0 200 100"), + Some(Rect::new(0.0, 0.0, 200.0, 100.0)) + ); + assert_eq!( + parse_viewbox("0,0,200,100"), + Some(Rect::new(0.0, 0.0, 200.0, 100.0)) + ); + assert_eq!( + parse_viewbox("-10 -5 220 110"), + Some(Rect::new(-10.0, -5.0, 210.0, 105.0)) + ); + } + + #[test] + fn rejects_malformed_viewbox() { + assert_eq!(parse_viewbox("0 0 200"), None); + assert_eq!(parse_viewbox("not a viewbox"), None); + } + + #[test] + fn default_preserve_aspect_ratio_is_xmidymid_meet() { + let par = parse_preserve_aspect_ratio(""); + assert_eq!(par.align, Align::XMidYMid); + assert_eq!(par.meet_or_slice, MeetOrSlice::Meet); + } + + #[test] + fn parses_none_and_slice() { + let par = parse_preserve_aspect_ratio("none"); + assert_eq!(par.align, Align::None); + let par = parse_preserve_aspect_ratio("xMinYMax slice"); + assert_eq!(par.align, Align::XMinYMax); + assert_eq!(par.meet_or_slice, MeetOrSlice::Slice); + } + + #[test] + fn defer_prefix_is_ignored() { + let par = parse_preserve_aspect_ratio("defer xMaxYMax meet"); + assert_eq!(par.align, Align::XMaxYMax); + } + + #[test] + fn meet_contain_scales_uniformly_and_centers() { + // viewBox 0 0 100 50, viewport 200 200: meet picks min(sx=2, sy=4) = 2, centered on the shorter axis. + let vb = Rect::new(0.0, 0.0, 100.0, 50.0); + let vp = Size::new(200.0, 200.0); + let ctm = viewbox_to_viewport_ctm(vb, vp, PreserveAspectRatio::default()); + assert_eq!(ctm * Point::new(0.0, 0.0), Point::new(0.0, 50.0)); + assert_eq!(ctm * Point::new(100.0, 50.0), Point::new(200.0, 150.0)); + } + + #[test] + fn none_align_scales_non_uniformly() { + let vb = Rect::new(0.0, 0.0, 100.0, 50.0); + let vp = Size::new(200.0, 200.0); + let par = PreserveAspectRatio { + align: Align::None, + meet_or_slice: MeetOrSlice::Meet, + }; + let ctm = viewbox_to_viewport_ctm(vb, vp, par); + assert_eq!(ctm * Point::new(100.0, 50.0), Point::new(200.0, 200.0)); + } + + #[test] + fn slice_cover_scales_uniformly_and_overflows() { + let vb = Rect::new(0.0, 0.0, 100.0, 50.0); + let vp = Size::new(200.0, 200.0); + let par = PreserveAspectRatio { + align: Align::XMidYMid, + meet_or_slice: MeetOrSlice::Slice, + }; + let ctm = viewbox_to_viewport_ctm(vb, vp, par); + // slice picks max(sx=2, sy=4) = 4; viewBox width*4 = 400 > viewport width 200, so it overflows. + assert_eq!(ctm * Point::new(100.0, 0.0), Point::new(400.0 - 100.0, 0.0)); + } +} diff --git a/packages/blitz-paint/Cargo.toml b/packages/blitz-paint/Cargo.toml index 525e2f3a8c..1f5c3e29bc 100644 --- a/packages/blitz-paint/Cargo.toml +++ b/packages/blitz-paint/Cargo.toml @@ -14,6 +14,8 @@ rust-version.workspace = true default = ["svg"] tracing = ["dep:tracing"] svg = ["dep:anyrender_svg", "dep:usvg", "blitz-dom/svg"] +# First-party inline rendering. Default off; additive-only. +svg-native = ["blitz-dom/svg-native"] custom-widget = ["blitz-dom/custom-widget"] font-embolden = [] apple-font-embolden = [] diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index bb9ce9bcf2..2cfa2ad03f 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -4,6 +4,8 @@ mod box_shadow; mod clip_path; mod form_controls; mod mask; +#[cfg(feature = "svg-native")] +mod svg; use std::collections::HashMap; use std::sync::Arc; @@ -455,7 +457,9 @@ impl<'dom, 'a> BlitzDomPainter<'dom, 'a> { y: -node.scroll_offset().y * self.scale, }); cx.draw_image(scene); - #[cfg(feature = "svg")] + #[cfg(feature = "svg-native")] + cx.draw_svg_fragment(scene); + #[cfg(all(feature = "svg", not(feature = "svg-native")))] cx.draw_svg(scene); #[cfg(feature = "custom-widget")] cx.draw_custom_widget(scene); @@ -544,6 +548,8 @@ impl<'dom, 'a> BlitzDomPainter<'dom, 'a> { transform, #[cfg(feature = "svg")] svg: element.svg_data(), + #[cfg(feature = "svg-native")] + svg_root: element.svg_root_data(), text_input: element.text_input_data(), list_item: element.list_item_data.as_deref(), devtools: self.dom.devtools(), @@ -589,7 +595,10 @@ struct ElementCx<'dom, 'a> { element: &'dom ElementData, transform: Affine, #[cfg(feature = "svg")] + #[cfg_attr(feature = "svg-native", allow(dead_code))] svg: Option<&'dom usvg::Tree>, + #[cfg(feature = "svg-native")] + svg_root: Option<&'dom blitz_dom::svg::SvgContext>, text_input: Option<&'dom TextInputData>, list_item: Option<&'dom ListItemLayout>, devtools: &'dom DevtoolSettings, @@ -928,6 +937,7 @@ impl ElementCx<'_, '_> { } #[cfg(feature = "svg")] + #[cfg_attr(feature = "svg-native", allow(dead_code))] fn draw_svg(&self, scene: &mut impl PaintScene) { use style::properties::generated::longhands::object_fit::computed_value::T as ObjectFit; diff --git a/packages/blitz-paint/src/render/svg.rs b/packages/blitz-paint/src/render/svg.rs new file mode 100644 index 0000000000..c18cedf6cd --- /dev/null +++ b/packages/blitz-paint/src/render/svg.rs @@ -0,0 +1,394 @@ +//! Paint a first-party inline `` fragment. + +use anyrender::PaintScene; +use blitz_dom::svg::SvgNodeKind; +use kurbo::{Affine, Shape, Stroke}; +use peniko::{Color, Fill}; + +use crate::color::ToColorColor; + +use super::ElementCx; + +impl ElementCx<'_, '_> { + pub(super) fn draw_svg_fragment(&self, scene: &mut impl PaintScene) { + let Some(ctx) = self.svg_root else { + return; + }; + + let base = self.transform * Affine::scale(self.scale); + let doc = self.node; + + for node in ctx.nodes.iter() { + // Group/Use/Image are geometry-less containers; nothing to paint for the container itself, + // so skip straight to the next node rather than opening a layer around an empty paint. + let (SvgNodeKind::Shape(_) | SvgNodeKind::Text(_) | SvgNodeKind::ForeignObject) = + &node.kind + else { + continue; + }; + + let dom_node = doc.with(node.dom_id); + let ctm = base * node.ctm; + + let Some(style) = dom_node.primary_styles() else { + continue; + }; + use style::properties::generated::longhands::visibility::computed_value::T as StyloVisibility; + if style.get_inherited_box().visibility != StyloVisibility::Visible { + continue; + } + + // CSS `opacity` does not inherit, but a group's opacity must still visually apply to everything + // painted inside it. Since painting is a flat linear scan with no nested layer stack, approximate + // group compositing by multiplying this leaf's own opacity by every ancestor `SvgNode`'s opacity + // along the `parent` chain. + let opacity = style.get_effects().opacity * self.ancestor_opacity(ctx, node.parent); + if opacity <= 0.0 { + continue; + } + + let attrs = dom_node.attrs().unwrap_or(&[]); + let current_color = resolve_current_color(&style); + + let needs_layer = opacity < 1.0; + if needs_layer { + let bbox = ctm * node.bbox.to_path(0.1); + scene.push_layer( + peniko::Mix::Normal, + opacity, + Affine::IDENTITY, + &bbox, + None, + None, + ); + } + + match &node.kind { + SvgNodeKind::Shape(path) => { + paint_shape(scene, ctm, path, attrs, current_color); + } + SvgNodeKind::Text(run) => { + self.draw_svg_text(scene, ctm, run, current_color); + } + SvgNodeKind::ForeignObject => { + let unbounded = kurbo::Rect::new(f64::MIN, f64::MIN, f64::MAX, f64::MAX); + for &child in dom_node.children.iter() { + self.context.render_node(scene, child, ctm, unbounded); + } + } + SvgNodeKind::Group | SvgNodeKind::Use { .. } | SvgNodeKind::Image => { + unreachable!("filtered out above") + } + } + + if needs_layer { + scene.pop_layer(); + } + } + } + + /// Product of `opacity` for every ancestor `SvgNode` above `parent` in the flat node list. + /// Elements with no computed style. + fn ancestor_opacity(&self, ctx: &blitz_dom::svg::SvgContext, parent: Option) -> f32 { + let mut opacity = 1.0f32; + let mut cur = parent; + while let Some(i) = cur { + let ancestor = &ctx.nodes[i as usize]; + if let Some(style) = self.node.with(ancestor.dom_id).primary_styles() { + opacity *= style.get_effects().opacity; + } + cur = ancestor.parent; + } + opacity + } + + fn draw_svg_text( + &self, + scene: &mut impl PaintScene, + ctm: Affine, + run: &blitz_dom::svg::TextRun, + current_color: Color, + ) { + use blitz_dom::svg::text::TextAnchor; + let full_width = run.layout.full_width(); + let anchor_dx = match run.anchor { + TextAnchor::Start => 0.0, + TextAnchor::Middle => -full_width / 2.0, + TextAnchor::End => -full_width, + } as f64; + let text_transform = ctm * Affine::translate((anchor_dx, 0.0)); + + for line in run.layout.lines() { + for item in line.items() { + let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + let run_ref = glyph_run.run(); + let font = run_ref.font(); + let font_size = run_ref.font_size(); + let glyph_xform: Option = None; + let coords = run_ref.normalized_coords(); + + let mut x = glyph_run.offset() as f64; + let y = glyph_run.baseline() as f64; + let glyphs = glyph_run.glyphs().map(move |g| { + let gx = x + g.x as f64; + x += g.advance as f64; + anyrender::Glyph { + id: g.id, + x: gx as f32, + y: y as f32, + } + }); + + scene.draw_glyphs( + font, + font_size, + true, + coords, + kurbo::Vec2::ZERO, + Fill::NonZero, + current_color, + 1.0, + text_transform, + glyph_xform, + glyphs, + ); + } + } + } +} + +fn paint_shape( + scene: &mut impl PaintScene, + ctm: Affine, + path: &kurbo::BezPath, + attrs: &[blitz_dom::node::Attribute], + current_color: Color, +) { + use blitz_dom::svg::attrs::raw_attr; + + let fill = raw_attr(attrs, "fill"); + let fill_rule = match raw_attr(attrs, "fill-rule") { + Some("evenodd") => Fill::EvenOdd, + _ => Fill::NonZero, + }; + let fill_opacity = raw_attr(attrs, "fill-opacity") + .and_then(parse_opacity) + .unwrap_or(1.0); + + // Unset `fill` defaults to black (SVG initial value); `fill: none` paints nothing. + if fill != Some("none") { + if let Some(mut color) = parse_paint(fill, current_color) { + color = color.multiply_alpha(fill_opacity); + scene.fill(fill_rule, ctm, color, None, path); + } + } + + let stroke = raw_attr(attrs, "stroke"); + if let Some(mut color) = stroke.and_then(|s| parse_paint(Some(s), current_color)) { + let stroke_opacity = raw_attr(attrs, "stroke-opacity") + .and_then(parse_opacity) + .unwrap_or(1.0); + let width = raw_attr(attrs, "stroke-width") + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(1.0); + // Stroke-width 0 skips the stroke phase entirely. + if width > 0.0 { + color = color.multiply_alpha(stroke_opacity); + let stroke_style = Stroke::new(width); + scene.stroke(&stroke_style, ctm, color, None, path); + } + } +} + +/// Resolve `fill`/`stroke` presentation-attribute *paint* values that this pass supports: `none` (caller-handled), +/// a solid color keyword/hex/rgb(), or `currentColor`. `url(#id)` paint-server references are not resolved here yet, +/// degrades to `None` (phase skipped), which is the "unresolvable -> fallback colour if given, else none" behaviour, +/// minus the fallback-color-after-`url()` parsing (`fill="url(#g) red"`) which is also not implemented yet. +fn parse_paint(value: Option<&str>, current_color: Color) -> Option { + let value = value?.trim(); + if value.is_empty() || value == "none" { + return None; + } + if value.starts_with("url(") { + return None; + } + parse_css_color(value, current_color) +} + +fn parse_opacity(value: &str) -> Option { + let value = value.trim(); + if let Some(pct) = value.strip_suffix('%') { + return pct + .trim() + .parse::() + .ok() + .map(|p| (p / 100.0).clamp(0.0, 1.0)); + } + value.parse::().ok().map(|v| v.clamp(0.0, 1.0)) +} + +/// Resolve the SVG paint value `currentColor` against the element's computed CSS `color`. +fn resolve_current_color(style: &style::properties::ComputedValues) -> Color { + style.get_inherited_text().color.as_srgb_color() +} + +/// Minimal CSS `` parser covering the SVG-in-the-wild common cases: +/// `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()`, `currentColor`, and a handful of named colors. +/// Full CSS color syntax is handled by the presentation-attribute cascade path (`svg/attrs.rs`) +/// for properties that go through it, this is the raw-attribute fallback used directly by shape painting. +fn parse_css_color(value: &str, current_color: Color) -> Option { + let value = value.trim(); + if value.eq_ignore_ascii_case("currentcolor") { + return Some(current_color); + } + if let Some(hex) = value.strip_prefix('#') { + return parse_hex_color(hex); + } + if let Some(inner) = value + .strip_prefix("rgba(") + .or_else(|| value.strip_prefix("rgb(")) + { + let inner = inner.strip_suffix(')')?; + let parts: Vec<&str> = inner.split(',').map(str::trim).collect(); + if parts.len() < 3 { + return None; + } + let component = |s: &str| -> Option { + if let Some(pct) = s.strip_suffix('%') { + Some((pct.trim().parse::().ok()? / 100.0).clamp(0.0, 1.0)) + } else { + Some((s.parse::().ok()? / 255.0).clamp(0.0, 1.0)) + } + }; + let r = component(parts[0])?; + let g = component(parts[1])?; + let b = component(parts[2])?; + let a = parts + .get(3) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1.0) + .clamp(0.0, 1.0); + return Some(Color::new([r, g, b, a])); + } + named_color(value) +} + +fn parse_hex_color(hex: &str) -> Option { + let to_f = |b: u8| b as f32 / 255.0; + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + Some(Color::new([to_f(r), to_f(g), to_f(b), 1.0])) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::new([to_f(r), to_f(g), to_f(b), 1.0])) + } + 8 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + let a = u8::from_str_radix(&hex[6..8], 16).ok()?; + Some(Color::new([to_f(r), to_f(g), to_f(b), to_f(a)])) + } + _ => None, + } +} + +fn named_color(name: &str) -> Option { + let rgb = match name.to_ascii_lowercase().as_str() { + "black" => (0, 0, 0), + "white" => (255, 255, 255), + "red" => (255, 0, 0), + "green" => (0, 128, 0), + "blue" => (0, 0, 255), + "yellow" => (255, 255, 0), + "orange" => (255, 165, 0), + "purple" => (128, 0, 128), + "gray" | "grey" => (128, 128, 128), + "silver" => (192, 192, 192), + "maroon" => (128, 0, 0), + "navy" => (0, 0, 128), + "teal" => (0, 128, 128), + "olive" => (128, 128, 0), + "lime" => (0, 255, 0), + "aqua" | "cyan" => (0, 255, 255), + "magenta" | "fuchsia" => (255, 0, 255), + "pink" => (255, 192, 203), + "brown" => (165, 42, 42), + "transparent" => return Some(Color::new([0.0, 0.0, 0.0, 0.0])), + _ => return None, + }; + Some(Color::new([ + rgb.0 as f32 / 255.0, + rgb.1 as f32 / 255.0, + rgb.2 as f32 / 255.0, + 1.0, + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BLACK: Color = Color::new([0.0, 0.0, 0.0, 1.0]); + + #[test] + fn parses_short_and_long_hex() { + assert_eq!( + parse_hex_color("f00"), + Some(Color::new([1.0, 0.0, 0.0, 1.0])) + ); + assert_eq!( + parse_hex_color("ff0000"), + Some(Color::new([1.0, 0.0, 0.0, 1.0])) + ); + } + + #[test] + fn parses_named_colors() { + assert_eq!( + parse_css_color("red", BLACK), + Some(Color::new([1.0, 0.0, 0.0, 1.0])) + ); + assert_eq!(parse_css_color("black", BLACK), Some(BLACK)); + } + + #[test] + fn resolves_current_color_keyword() { + let cc = Color::new([0.2, 0.4, 0.6, 1.0]); + assert_eq!(parse_css_color("currentColor", cc), Some(cc)); + } + + #[test] + fn rejects_url_references() { + assert_eq!(parse_paint(Some("url(#grad)"), BLACK), None); + } + + #[test] + fn none_and_unset_paint_are_no_paint() { + assert_eq!(parse_paint(Some("none"), BLACK), None); + assert_eq!(parse_paint(None, BLACK), None); + } + + #[test] + fn parses_rgb_function() { + assert_eq!( + parse_css_color("rgb(255, 0, 0)", BLACK), + Some(Color::new([1.0, 0.0, 0.0, 1.0])) + ); + } + + #[test] + fn opacity_parses_percent_and_clamps() { + assert_eq!(parse_opacity("50%"), Some(0.5)); + assert_eq!(parse_opacity("2"), Some(1.0)); + assert_eq!(parse_opacity("-1"), Some(0.0)); + } +} diff --git a/tests/blitz-tests/Cargo.toml b/tests/blitz-tests/Cargo.toml index 93b0d6bb85..e061691e0c 100644 --- a/tests/blitz-tests/Cargo.toml +++ b/tests/blitz-tests/Cargo.toml @@ -13,10 +13,10 @@ rust-version.workspace = true [dev-dependencies] # Blitz dependencies blitz-test-harness = { workspace = true } -blitz-dom = { workspace = true, features = ["accessibility", "floats", "system-fonts"] } +blitz-dom = { workspace = true, features = ["accessibility", "floats", "system-fonts", "svg-native"] } blitz-html = { workspace = true } blitz-traits = { workspace = true } -blitz-paint = { workspace = true, features = ["scrollbars", "svg"] } +blitz-paint = { workspace = true, features = ["scrollbars", "svg", "svg-native"] } dioxus-native-dom = { workspace = true } anyrender = { workspace = true } anyrender_vello_cpu = { workspace = true } @@ -30,6 +30,7 @@ accesskit = { workspace = true } markup5ever = { workspace = true } keyboard-types = { workspace = true } usvg = { workspace = true } +kurbo = { workspace = true } [lib] path = "lib.rs" diff --git a/tests/blitz-tests/tests/svg_group_opacity.rs b/tests/blitz-tests/tests/svg_group_opacity.rs new file mode 100644 index 0000000000..39bf61f47b --- /dev/null +++ b/tests/blitz-tests/tests/svg_group_opacity.rs @@ -0,0 +1,93 @@ +//! CSS `opacity` does not inherit, but a ``'s value must still visually apply to everything painted inside it. +//! Regression test for a bug where the flat per-node paint loop opened and closed an opacity layer around the *group's own* +//! paint call, leaving descendants, separate iterations of the same flat loop, painted at full opacity regardless of any +//! ancestor group's opacity. + +use anyrender::render_to_buffer; +use anyrender_vello_cpu::VelloCpuImageRenderer; +use blitz_dom::DocumentConfig; +use blitz_html::{HtmlDocument, HtmlProvider}; +use blitz_paint::paint_scene; +use blitz_traits::shell::{ColorScheme, Viewport}; +use std::sync::Arc; + +fn center_pixel(html: &str) -> [u8; 3] { + let mut doc = HtmlDocument::from_html( + html, + DocumentConfig { + viewport: Some(Viewport::new(100, 100, 1.0, ColorScheme::Light)), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + }, + ); + doc.resolve(0.0); + let buffer = render_to_buffer::( + |scene| paint_scene(scene, &mut doc, 1.0, 100, 100, 0, 0), + 100, + 100, + ); + let idx = (50 * 100 + 50) * 4; + [buffer[idx], buffer[idx + 1], buffer[idx + 2]] +} + +#[test] +fn group_opacity_blends_its_descendant_shape_with_the_background() { + let px = center_pixel( + r##" + + + + + + "##, + ); + // 50% red over a white background: full opacity would read as pure red; + // ignoring the group's opacity entirely (the bug) produces exactly that. + // Blended, red stays saturated while green/blue rise partway back towards white. + assert_ne!(px, [255, 0, 0], "group opacity must not be ignored"); + assert_eq!( + px[0], 255, + "red channel is already saturated in both layers" + ); + assert!( + px[1] > 100 && px[1] < 200, + "green channel should sit roughly halfway between 0 (red) and 255 (white), got {}", + px[1] + ); + assert_eq!( + px[1], px[2], + "the blend is achromatic on the green/blue axes" + ); +} + +#[test] +fn nested_group_opacity_compounds_multiplicatively() { + let single = center_pixel( + r##" + + + + + + "##, + ); + let nested = center_pixel( + r##" + + + + + + + + "##, + ); + // 0.5 * 0.5 = 0.25 total coverage, lighter (closer to white) than a single 0.5 group. + assert!( + nested[1] > single[1], + "two nested 0.5-opacity groups (0.25 total) should be lighter than one 0.5 group, \ + got nested={:?} single={:?}", + nested, + single + ); +} diff --git a/tests/blitz-tests/tests/svg_native.rs b/tests/blitz-tests/tests/svg_native.rs new file mode 100644 index 0000000000..2d041b3b8a --- /dev/null +++ b/tests/blitz-tests/tests/svg_native.rs @@ -0,0 +1,180 @@ +//! Integration tests for first-party inline SVG. +//! +//! Mirrors the harness pattern in `svg_attr_sizing.rs`. These specifically exercise sizing behaviour that is shared +//! between the `usvg` and `svg-native` paths so they double as a parity check: the observable box size of a root +//! `` must not change based on which rendering backend produced it. + +use blitz_dom::DocumentConfig; +use blitz_html::{HtmlDocument, HtmlProvider}; +use blitz_traits::shell::{ColorScheme, Viewport}; +use std::sync::Arc; + +fn layout_doc(html: &str) -> HtmlDocument { + let mut doc = HtmlDocument::from_html( + html, + DocumentConfig { + viewport: Some(Viewport::new(800, 600, 1.0, ColorScheme::Light)), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + }, + ); + doc.resolve(0.0); + doc +} + +fn svg_size(html: &str) -> (f32, f32) { + let doc = layout_doc(html); + let svg_id = doc + .query_selector("#icon") + .unwrap() + .expect("#icon not found"); + let layout = doc.get_node(svg_id).unwrap().final_layout(); + (layout.size.width, layout.size.height) +} + +#[test] +fn default_viewport_is_300x150() { + let size = svg_size(r#""#); + assert_eq!(size, (300.0, 150.0)); +} + +#[test] +fn explicit_dimensions_are_honoured() { + let size = svg_size( + r#" + + "#, + ); + assert_eq!(size, (48.0, 32.0)); +} + +#[test] +fn percentage_width_resolves_against_containing_block() { + let size = svg_size( + r#" +
+ +
+ "#, + ); + assert_eq!(size, (100.0, 30.0)); +} + +#[test] +fn css_width_overrides_presentation_attributes() { + let size = svg_size( + r#" + + "#, + ); + assert_eq!(size, (60.0, 60.0)); +} + +#[test] +fn deeply_nested_shapes_do_not_panic_construction_or_layout() { + let _doc = layout_doc( + r##" + + + + + + + + + + + + + + hello + + + "##, + ); +} + +#[test] +fn nested_svg_establishes_its_own_viewport() { + use blitz_dom::svg::SvgNodeKind; + use kurbo::Point; + + let doc = layout_doc( + r##" + + + + + + "##, + ); + let svg_id = doc.query_selector("#icon").unwrap().unwrap(); + let ctx = doc + .get_node(svg_id) + .unwrap() + .element_data() + .unwrap() + .svg_root_data() + .expect("svg root should have a constructed SvgContext"); + + let rect = ctx + .nodes + .iter() + .find(|n| matches!(n.kind, SvgNodeKind::Shape(_))) + .expect("nested should still be walked into the flat node list"); + + let origin = rect.ctm * Point::new(0.0, 0.0); + let corner = rect.ctm * Point::new(25.0, 25.0); + assert_eq!(origin, Point::new(10.0, 20.0)); + assert_eq!(corner, Point::new(60.0, 70.0)); +} + +#[test] +fn malformed_attributes_degrade_gracefully() { + let _doc = layout_doc( + r##" + + + + + + "##, + ); +} + +#[test] +fn use_self_reference_cycle_does_not_hang_or_panic() { + // A that (transitively) targets itself must be caught by the cycle guard, not recurse forever. + let _doc = layout_doc( + r##" + + + + "##, + ); +} + +#[test] +fn use_targeting_own_ancestor_is_caught_immediately_by_the_ancestor_guard() { + let doc = layout_doc( + r##" + + + + "##, + ); + let svg_id = doc.query_selector("#icon").unwrap().unwrap(); + let ctx = doc + .get_node(svg_id) + .unwrap() + .element_data() + .unwrap() + .svg_root_data() + .expect("svg root should have a constructed SvgContext"); + assert_eq!( + ctx.nodes.len(), + 1, + "ancestor-cycle guard should reject the on its first attempt, \ + not expand it MAX_REF_DEPTH times before the depth-cap backstop kicks in" + ); +} From f38ce8da61eb57ba441efb864c831801d9a07677 Mon Sep 17 00:00:00 2001 From: Ritesh Date: Wed, 12 Aug 2026 13:25:39 +0000 Subject: [PATCH 2/5] Keep system-fonts on in the svg feature matrix job --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df6e77611d..94a3a2ae87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ jobs: - name: Build feature flags id: features run: | - FEATURES="" + FEATURES="system-fonts," if [ "${{ matrix.svg }}" = "true" ]; then FEATURES="${FEATURES}svg," fi From 6346c8ee9bb3f5c17282062a5f5f55db9fa544b4 Mon Sep 17 00:00:00 2001 From: Ritesh Date: Thu, 13 Aug 2026 05:43:51 +0000 Subject: [PATCH 3/5] svg-native: fix damage, paint, hit-test, and construction bugs --- Cargo.toml | 1 + examples/svg_native.rs | 36 ++ packages/blitz-dom/src/document.rs | 18 +- packages/blitz-dom/src/layout/construct.rs | 1 + packages/blitz-dom/src/layout/damage.rs | 3 +- packages/blitz-dom/src/stylo.rs | 49 +-- packages/blitz-dom/src/svg/construct.rs | 39 +- packages/blitz-dom/src/svg/hit_test.rs | 88 ++-- packages/blitz-dom/src/traversal.rs | 29 ++ packages/blitz-paint/src/render/svg.rs | 411 ++++++++----------- packages/dioxus-native-dom/Cargo.toml | 1 + packages/dioxus-native/Cargo.toml | 1 + tests/blitz-tests/tests/svg_group_opacity.rs | 46 +++ tests/blitz-tests/tests/svg_native.rs | 110 +++++ 14 files changed, 530 insertions(+), 303 deletions(-) create mode 100644 examples/svg_native.rs diff --git a/Cargo.toml b/Cargo.toml index a408d318e7..3f8fafc55d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -259,6 +259,7 @@ dioxus-native = { workspace = true, features = [ "vello-hybrid", "floats", "svg", + "svg-native", "prelude", ] } euclid = { workspace = true } diff --git a/examples/svg_native.rs b/examples/svg_native.rs new file mode 100644 index 0000000000..9bcfedbced --- /dev/null +++ b/examples/svg_native.rs @@ -0,0 +1,36 @@ +//! First-party inline SVG (`svg-native`): the `` below is parsed straight into +//! Blitz's DOM, not painted as an opaque external image, so ordinary CSS including +//! `:hover` applies to elements inside it. +use dioxus_native::prelude::*; + +fn main() { + dioxus_native::launch(app); +} + +fn app() -> Element { + rsx! { + style { r#" + body {{ font-family: sans-serif; margin: 24px; }} + .icon-btn rect {{ transition: fill 0.15s; }} + .icon-btn:hover rect {{ fill: #ff5533; }} + "# } + h1 { "svg-native demo" } + + div { style: "display: flex; gap: 24px; align-items: center;", + + svg { width: "80", height: "80", "viewBox": "0 0 80 80", + g { fill: "#3366ff", + rect { x: "10", y: "10", width: "60", height: "60", rx: "8" } + } + } + + svg { width: "80", height: "80", "viewBox": "0 0 80 80", style: "color: #22aa55;", + circle { cx: "40", cy: "40", r: "30", fill: "currentColor" } + } + + svg { class: "icon-btn", width: "80", height: "80", "viewBox": "0 0 80 80", + rect { x: "10", y: "10", width: "60", height: "60", rx: "8", fill: "#8888aa" } + } + } + } +} diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 85ec4ea073..dc13449d2f 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -312,6 +312,12 @@ pub struct BaseDocument { /// Nodes that contain custom widgets #[cfg(feature = "custom-widget")] pub(crate) custom_widget_nodes: HashSet, + /// Root `` elements (`SpecialElementData::SvgRoot`), so per-layout fragment rebuild + /// doesn't have to scan the whole node slotmap looking for them. Entries are inserted once, + /// on first becoming an `SvgRoot`, and never explicitly removed; `rebuild_svg_fragments` + /// self-prunes ids that no longer resolve to a live `SvgRoot`. + #[cfg(feature = "svg-native")] + pub(crate) svg_root_nodes: HashSet, /// Rendering resources allocated by custom widgets that should be deallocated during the next render #[cfg(feature = "custom-widget")] pub(crate) pending_resource_deallocations: Vec, @@ -486,6 +492,8 @@ impl BaseDocument { custom_widget_nodes: HashSet::new(), #[cfg(feature = "custom-widget")] pending_resource_deallocations: Vec::new(), + #[cfg(feature = "svg-native")] + svg_root_nodes: HashSet::new(), changed_nodes: HashSet::new(), deferred_construction_nodes: Vec::new(), @@ -1552,7 +1560,7 @@ impl BaseDocument { ); let active_node_id = Some(hover_node_id); - let node_path = self.maybe_node_layout_ancestors(active_node_id); + let node_path = self.maybe_node_interaction_ancestors(active_node_id); for &id in node_path.iter() { self.snapshot_node_and(id, |node| node.active()); } @@ -1567,7 +1575,7 @@ impl BaseDocument { return false; }; - let node_path = self.maybe_node_layout_ancestors(Some(active_node_id)); + let node_path = self.maybe_node_interaction_ancestors(Some(active_node_id)); for &id in node_path.iter() { self.snapshot_node_and(id, |node| node.unactive()); } @@ -1701,8 +1709,8 @@ impl BaseDocument { return scrollbar_changed; } - let old_node_path = self.maybe_node_layout_ancestors(self.hover_node_id); - let new_node_path = self.maybe_node_layout_ancestors(hover_node_id); + let old_node_path = self.maybe_node_interaction_ancestors(self.hover_node_id); + let new_node_path = self.maybe_node_interaction_ancestors(hover_node_id); let same_count = old_node_path .iter() .zip(&new_node_path) @@ -1736,7 +1744,7 @@ impl BaseDocument { return false; }; - let old_node_path = self.maybe_node_layout_ancestors(Some(hover_node_id)); + let old_node_path = self.maybe_node_interaction_ancestors(Some(hover_node_id)); for &id in old_node_path.iter() { self.snapshot_node_and(id, |node| node.unhover()); } diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index bdcb5d7568..7c802adf22 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -421,6 +421,7 @@ pub(crate) fn collect_layout_children( } element_data.take_inline_layout(); } + doc.svg_root_nodes.insert(container_node_id); doc.nodes[container_node_id].insert_damage(crate::layout::damage::CONSTRUCT_SVG); return; } diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index cfde8cd068..b5bf7ed0ec 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -37,7 +37,8 @@ pub(crate) const CONSTRUCT_SVG: RestyleDamage = RestyleDamage::from_bits_retain(0b_0000_0000_1000_0000); pub(crate) const ALL_DAMAGE: RestyleDamage = RestyleDamage::from_bits_retain( - CONSTRUCT_BOX.bits() + RestyleDamage::RELAYOUT.bits() + | CONSTRUCT_BOX.bits() | CONSTRUCT_FC.bits() | CONSTRUCT_DESCENDENT.bits() | ONLY_RELAYOUT.bits() diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index e4916461cc..0ea9337f7b 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -57,23 +57,21 @@ use style_dom::ElementState; use style::values::computed::text::TextAlign as StyloTextAlign; -// `synthesize_presentational_hints_for_legacy_attributes` only gets `&self with no path back to the owning -// `BaseDocument`, but SVG presentation-attribute parsing needs a `UrlExtraData` to hand to Stylo's declaration -// parser. Stash it here for the duration of style resolution, mirroring the thread-local pattern already used for the -// parley layout context elsewhere in this crate. +// `synthesize_presentational_hints_for_legacy_attributes` only gets `&self` with no path back to +// the owning `BaseDocument`, but SVG presentation-attribute parsing needs a `UrlExtraData` to +// hand to Stylo's declaration parser. #[cfg(feature = "svg-native")] -thread_local! { - static SVG_URL_EXTRA_DATA: std::cell::RefCell> = - const { std::cell::RefCell::new(None) }; -} +static SVG_URL_EXTRA_DATA: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + style::stylesheets::UrlExtraData(style::servo_arc::Arc::new( + url::Url::parse("about:blank").unwrap(), + )) + }); impl crate::document::BaseDocument { pub fn resolve_stylist(&mut self, now: f64) { style::thread_state::enter(ThreadState::LAYOUT); - #[cfg(feature = "svg-native")] - SVG_URL_EXTRA_DATA.with(|cell| *cell.borrow_mut() = Some(self.url.url_extra_data())); - let guard = &self.guard; let guards = StylesheetGuards { author: &guard.read(), @@ -854,22 +852,19 @@ impl<'a> TElement for BlitzNode<'a> { // SVG presentation attributes. Gated on both the feature flag and the element's namespace. #[cfg(feature = "svg-native")] if elem.name.ns == markup5ever::ns!(svg) { - let url_data = SVG_URL_EXTRA_DATA.with(|cell| cell.borrow().clone()); - if let Some(url_data) = url_data { - for attr in elem.attrs() { - if let Some(mut source_decl) = crate::svg::attrs::svg_presentation_hint( - &attr.name.local, - &attr.value, - &url_data, - ) { - let mut block = PropertyDeclarationBlock::new(); - block.extend(source_decl.drain(), Importance::Normal); - hints.push(ApplicableDeclarationBlock::from_declarations( - Arc::new(self.guard().wrap(block)), - CascadeLevel::new(CascadeOrigin::PresHints), - LayerOrder::root(), - )); - } + for attr in elem.attrs() { + if let Some(mut source_decl) = crate::svg::attrs::svg_presentation_hint( + &attr.name.local, + &attr.value, + &SVG_URL_EXTRA_DATA, + ) { + let mut block = PropertyDeclarationBlock::new(); + block.extend(source_decl.drain(), Importance::Normal); + hints.push(ApplicableDeclarationBlock::from_declarations( + Arc::new(self.guard().wrap(block)), + CascadeLevel::new(CascadeOrigin::PresHints), + LayerOrder::root(), + )); } } } diff --git a/packages/blitz-dom/src/svg/construct.rs b/packages/blitz-dom/src/svg/construct.rs index 15e0c2ef52..cc74c37c5d 100644 --- a/packages/blitz-dom/src/svg/construct.rs +++ b/packages/blitz-dom/src/svg/construct.rs @@ -55,23 +55,41 @@ fn is_non_rendered_container(tag: &str) -> bool { /// `SvgContext` yet or carries `CONSTRUCT_SVG` damage (sibling `` /// roots are left untouched). Called once per layout pass, after Taffy /// layout completes. +/// +/// Iterates the `svg_root_nodes` registry rather than the whole node +/// slotmap, so a document with zero `` roots pays zero per-layout +/// discovery cost. The registry can carry stale ids. +/// Both cases are pruned here rather than at every mutation site. pub fn rebuild_svg_fragments(doc: &mut BaseDocument) { let mut roots: Vec = Vec::new(); - for (id, node) in doc.nodes.iter() { + let mut stale: Vec = Vec::new(); + + for &id in doc.svg_root_nodes.iter() { + let Some(node) = doc.nodes.get(id) else { + stale.push(id); + continue; + }; let Some(elem) = node.data.downcast_element() else { + stale.push(id); continue; }; - if matches!(elem.special_data, SpecialElementData::SvgRoot(_)) { - let needs_rebuild = node - .damage() - .unwrap_or(CONSTRUCT_SVG) - .contains(CONSTRUCT_SVG); - if needs_rebuild { - roots.push(id); - } + if !matches!(elem.special_data, SpecialElementData::SvgRoot(_)) { + stale.push(id); + continue; + } + let needs_rebuild = node + .damage() + .unwrap_or(CONSTRUCT_SVG) + .contains(CONSTRUCT_SVG); + if needs_rebuild { + roots.push(id); } } + for id in stale { + doc.svg_root_nodes.remove(&id); + } + for root in roots { let ctx = construct_svg_fragment(doc, root); if let Some(elem) = doc.nodes[root].element_data_mut() { @@ -470,6 +488,7 @@ fn walk( } _ => use_ctm, }; + let inner_viewport = Size::new(use_w, use_h); for &child in doc.nodes[target].children.iter() { walk( doc, @@ -477,7 +496,7 @@ fn walk( inner_ctm, Some(idx), id_map, - viewport, + inner_viewport, nodes, use_depth + 1, budget, diff --git a/packages/blitz-dom/src/svg/hit_test.rs b/packages/blitz-dom/src/svg/hit_test.rs index 1700e03bdf..64035e282a 100644 --- a/packages/blitz-dom/src/svg/hit_test.rs +++ b/packages/blitz-dom/src/svg/hit_test.rs @@ -7,12 +7,33 @@ use kurbo::{Point, Shape, Stroke}; use super::context::{SvgContext, SvgNodeKind}; use style::properties::generated::longhands::visibility::computed_value::T as StyloVisibility; -/// Whether an SVG paint value counts as "painted" for hit testing purposes. -fn is_painted(raw_paint_attr: Option<&str>, default_none: bool) -> bool { - match raw_paint_attr.map(str::trim) { - Some("none") => false, - Some(_) => true, - None => !default_none, +/// Whether a computed SVG paint counts as "painted" for hit testing purposes. +/// Stylo already resolves the SVG initial values into a concrete `SVGPaintKind`, so there's no +/// separate "unset" case to handle here the way there was for raw attribute strings. +fn is_painted(paint: &style::values::computed::svg::SVGPaint) -> bool { + use style::values::computed::svg::SVGPaintKind; + use style::values::generics::svg::SVGPaintFallback; + match &paint.kind { + SVGPaintKind::None => false, + SVGPaintKind::Color(_) => true, + SVGPaintKind::PaintServer(_) => matches!(paint.fallback, SVGPaintFallback::Color(_)), + SVGPaintKind::ContextFill | SVGPaintKind::ContextStroke => false, + } +} + +/// Computed `stroke-width` resolved to user units (px), mirroring `blitz_paint::render::svg::svg_length_px`. +fn stroke_width_px(width: &style::values::computed::svg::SVGWidth, viewport: kurbo::Size) -> f64 { + use style::values::computed::length::Length; + use style::values::computed::length_percentage::Unpacked; + use style::values::computed::svg::SVGWidth; + let diag = super::geometry::diagonal_basis(viewport.width, viewport.height); + match width { + SVGWidth::LengthPercentage(lp) => match lp.0.unpack() { + Unpacked::Length(l) => l.px() as f64, + Unpacked::Percentage(p) => p.0 as f64 * diag, + Unpacked::Calc(c) => c.resolve(Length::new(diag as f32)).px() as f64, + }, + SVGWidth::ContextValue => 1.0, } } @@ -23,6 +44,8 @@ pub fn hit_test( ctx: &SvgContext, point: Point, ) -> Option { + use style::computed_values::pointer_events::T as PointerEvents; + for node in ctx.nodes.iter().rev() { let SvgNodeKind::Shape(path) = &node.kind else { continue; @@ -34,29 +57,27 @@ pub fn hit_test( let local_point = inverse * point; let dom_node = tree_node.with(node.dom_id); - let attrs = dom_node.attrs().unwrap_or(&[]); + let Some(style) = dom_node.primary_styles() else { + continue; + }; - if let Some(style) = dom_node.primary_styles() { - if style.get_inherited_box().visibility != StyloVisibility::Visible { - continue; - } + if style.get_inherited_box().visibility != StyloVisibility::Visible { + continue; } - - if super::attrs::raw_attr(attrs, "pointer-events") == Some("none") { + if style.clone_pointer_events() == PointerEvents::None { continue; } - let fill_painted = is_painted(super::attrs::raw_attr(attrs, "fill"), false); - let stroke_painted = is_painted(super::attrs::raw_attr(attrs, "stroke"), true); + let svg = style.get_inherited_svg(); + let fill_painted = is_painted(&svg.fill); + let stroke_painted = is_painted(&svg.stroke); if fill_painted && path.contains(local_point) { return Some(node.dom_id); } if stroke_painted { - let stroke_width = super::attrs::raw_attr(attrs, "stroke-width") - .and_then(|v| v.trim().parse::().ok()) - .unwrap_or(1.0); + let stroke_width = stroke_width_px(&svg.stroke_width, ctx.viewport); if stroke_width > 0.0 { let stroke = Stroke::new(stroke_width); let outline = kurbo::stroke( @@ -87,26 +108,35 @@ fn try_invert(affine: kurbo::Affine) -> Option { #[cfg(test)] mod tests { use super::*; - - #[test] - fn unset_fill_defaults_to_painted() { - assert!(is_painted(None, false)); + use style::color::AbsoluteColor; + use style::values::computed::color::Color as ComputedColor; + use style::values::computed::svg::{SVGPaint, SVGPaintKind}; + use style::values::generics::svg::SVGPaintFallback; + + fn color_paint() -> SVGPaint { + SVGPaint { + kind: SVGPaintKind::Color(ComputedColor::Absolute(AbsoluteColor::BLACK)), + fallback: SVGPaintFallback::Unset, + } } #[test] - fn unset_stroke_defaults_to_not_painted() { - assert!(!is_painted(None, true)); + fn none_paint_is_never_painted() { + let none = SVGPaint { + kind: SVGPaintKind::None, + fallback: SVGPaintFallback::Unset, + }; + assert!(!is_painted(&none)); } #[test] - fn explicit_none_is_never_painted() { - assert!(!is_painted(Some("none"), false)); - assert!(!is_painted(Some("none"), true)); + fn color_paint_is_painted() { + assert!(is_painted(&color_paint())); } #[test] - fn explicit_color_is_painted() { - assert!(is_painted(Some("red"), true)); + fn fill_initial_value_is_opaque_black_and_painted() { + assert!(is_painted(&SVGPaint::BLACK)); } #[test] diff --git a/packages/blitz-dom/src/traversal.rs b/packages/blitz-dom/src/traversal.rs index 036a2a393e..271f713ad8 100644 --- a/packages/blitz-dom/src/traversal.rs +++ b/packages/blitz-dom/src/traversal.rs @@ -323,6 +323,35 @@ impl BaseDocument { .unwrap_or_default() } + /// Ancestors of `node_id` in document order, for `:hover`/`:active` propagation. + /// Starts at `node_id` and follows the layout-parent chain like `node_layout_ancestors`, + /// but when a node has no layout parent at all it climbs DOM parents until it reaches one + /// that does, so the chain still reaches the enclosing HTML element. + pub fn node_interaction_ancestors(&self, node_id: NodeId) -> Vec { + let mut ancestors = Vec::with_capacity(12); + let mut maybe_id = Some(node_id); + while let Some(id) = maybe_id { + ancestors.push(id); + if let Some(layout_parent) = self.nodes[id].layout_parent.get() { + let mut maybe = Some(layout_parent); + while let Some(pid) = maybe { + ancestors.push(pid); + maybe = self.nodes[pid].layout_parent.get(); + } + break; + } + maybe_id = self.nodes[id].parent; + } + ancestors.reverse(); + ancestors + } + + pub fn maybe_node_interaction_ancestors(&self, node_id: Option) -> Vec { + node_id + .map(|id| self.node_interaction_ancestors(id)) + .unwrap_or_default() + } + /// Compare the document order of two nodes. /// Returns Ordering::Less if node_a comes before node_b in document order. /// Returns Ordering::Greater if node_a comes after node_b. diff --git a/packages/blitz-paint/src/render/svg.rs b/packages/blitz-paint/src/render/svg.rs index c18cedf6cd..6e1444360f 100644 --- a/packages/blitz-paint/src/render/svg.rs +++ b/packages/blitz-paint/src/render/svg.rs @@ -2,13 +2,22 @@ use anyrender::PaintScene; use blitz_dom::svg::SvgNodeKind; -use kurbo::{Affine, Shape, Stroke}; +use kurbo::{Affine, Rect, Shape, Stroke}; use peniko::{Color, Fill}; use crate::color::ToColorColor; use super::ElementCx; +/// One entry in the DFS-path stack `draw_svg_fragment` walks alongside the flat, pre-order +/// `ctx.nodes` list. `ctx.nodes` is exactly a pre-order traversal with parent indices, so a +/// node's subtree is a contiguous run: popping frames whose index isn't the next node's parent +/// closes exactly the subtrees that have been fully visited, no ancestor-chain walk needed. +struct Frame { + idx: u32, + layer_open: bool, +} + impl ElementCx<'_, '_> { pub(super) fn draw_svg_fragment(&self, scene: &mut impl PaintScene) { let Some(ctx) = self.svg_root else { @@ -17,57 +26,83 @@ impl ElementCx<'_, '_> { let base = self.transform * Affine::scale(self.scale); let doc = self.node; + let viewport = ctx.viewport; + // Clip source for a *group's* opacity layer: groups have no accumulated bounding box, so clip to + // the fragment's own viewport in base space instead. Always finite, never over-tight enough to clip real content. + let fragment_clip = base * Rect::new(0.0, 0.0, viewport.width, viewport.height).to_path(0.1); - for node in ctx.nodes.iter() { - // Group/Use/Image are geometry-less containers; nothing to paint for the container itself, - // so skip straight to the next node rather than opening a layer around an empty paint. - let (SvgNodeKind::Shape(_) | SvgNodeKind::Text(_) | SvgNodeKind::ForeignObject) = - &node.kind - else { - continue; - }; + let mut stack: Vec + + + + + + + "##, + ); + let single_blue = center_pixel( + r##" + + + + "##, + ); + assert_eq!( + grouped, single_blue, + "opaque blue fully covering red inside one opacity group should read identically to a \ + single 50%-opacity blue rect over the same background, got grouped={:?} single_blue={:?}", + grouped, single_blue + ); +} + +#[test] +fn stroked_shape_at_partial_opacity_keeps_its_stroke() { + // The opacity layer's clip must include the stroke's outset, not just the fill bbox. + let px = center_pixel( + r##" + + + + "##, + ); + assert_ne!( + px, [255, 255, 255], + "the stroke should reach the canvas center even through a 0.5-opacity layer whose clip \ + is derived from a zero-height fill bbox, but the pixel is untouched white" + ); +} diff --git a/tests/blitz-tests/tests/svg_native.rs b/tests/blitz-tests/tests/svg_native.rs index 2d041b3b8a..b9379619d6 100644 --- a/tests/blitz-tests/tests/svg_native.rs +++ b/tests/blitz-tests/tests/svg_native.rs @@ -178,3 +178,113 @@ fn use_targeting_own_ancestor_is_caught_immediately_by_the_ancestor_guard() { not expand it MAX_REF_DEPTH times before the depth-cap backstop kicks in" ); } + +#[test] +fn removed_svg_root_does_not_panic_the_next_layout_pass() { + let mut doc = layout_doc( + r##" + + "##, + ); + let svg_id = doc.query_selector("#icon").unwrap().unwrap(); + doc.mutate().remove_and_drop_node(svg_id); + doc.resolve(0.0); +} + +#[test] +fn bare_shape_with_no_fill_declared_anywhere_paints_black() { + // The SVG initial value for `fill` is opaque black. `paint_shape` reads this from computed + // style (`get_inherited_svg().fill`), so a shape with no `fill` attribute and no CSS rule + // must still resolve to `SVGPaintKind::Color(black)`, not "no paint". + let doc = layout_doc( + r##" + + "##, + ); + let rect_id = doc.query_selector("#r").unwrap().unwrap(); + let style = doc.get_node(rect_id).unwrap().primary_styles().unwrap(); + use style::values::computed::svg::SVGPaintKind; + assert!(matches!(style.get_inherited_svg().fill.kind, SVGPaintKind::Color(_))); +} + +#[test] +fn fill_on_group_inherits_to_child_shape() { + // ``: `fill` is an inherited CSS property, so the presentation + // attribute on the group must cascade down to the ``'s computed style, not just apply + // to the group's own (nonexistent) geometry. + let doc = layout_doc( + r##" + + + + "##, + ); + let rect_id = doc.query_selector("#r").unwrap().unwrap(); + let style = doc.get_node(rect_id).unwrap().primary_styles().unwrap(); + use style::values::computed::color::Color as ComputedColor; + use style::values::computed::svg::SVGPaintKind; + let SVGPaintKind::Color(ComputedColor::Absolute(c)) = &style.get_inherited_svg().fill.kind + else { + panic!("expected an absolute color"); + }; + assert_eq!(c.components.0, 1.0, "red channel"); + assert_eq!(c.components.1, 0.0, "green channel"); + assert_eq!(c.components.2, 0.0, "blue channel"); +} + +#[test] +fn use_targeting_symbol_resolves_child_percentages_against_the_use_size_not_the_outer_canvas() { + // A `` targeting a `` establishes an inner viewport of `use.width x use.height`; + // a percentage-sized child inside the `` must resolve against *that*, not the outer + // ``'s canvas (a prior bug passed the outer `viewport` through to the recursive walk + // instead of `Size::new(use_w, use_h)`). + use blitz_dom::svg::SvgNodeKind; + + let doc = layout_doc( + r##" + + + + + "##, + ); + let svg_id = doc.query_selector("#icon").unwrap().unwrap(); + let ctx = doc + .get_node(svg_id) + .unwrap() + .element_data() + .unwrap() + .svg_root_data() + .unwrap(); + let shape = ctx + .nodes + .iter() + .find(|n| matches!(n.kind, SvgNodeKind::Shape(_))) + .expect("rect inside the should be shadow-expanded into the flat node list"); + assert_eq!(shape.bbox.width(), 20.0); + assert_eq!(shape.bbox.height(), 20.0); +} + +#[test] +fn hovering_svg_shape_reports_enclosing_html_ancestor_as_hovered() { + // SVG shapes never get a Taffy `layout_parent` (only the root `` participates in + // HTML layout), so the old `maybe_node_layout_ancestors` chain for a hovered shape was just + // `[shape_id]`. + let mut doc = HtmlDocument::from_html( + r##" +
+ "##, + DocumentConfig { + viewport: Some(Viewport::new(800, 600, 1.0, ColorScheme::Light)), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + }, + ); + doc.resolve(0.0); + doc.set_hover_to(10.0, 10.0); + let card_id = doc.query_selector("#card").unwrap().unwrap(); + assert!( + doc.get_node(card_id).unwrap().is_hovered(), + "hovering the icon inside #card should keep #card itself marked hovered" + ); +} From d299110289f91c9786cc52d6b3dd16528a2991a9 Mon Sep 17 00:00:00 2001 From: Ritesh Date: Fri, 14 Aug 2026 04:40:42 +0000 Subject: [PATCH 4/5] Added svg transform property and fixed minor bugs --- packages/blitz-dom/src/stylo.rs | 2 +- packages/blitz-dom/src/svg/attrs.rs | 64 ++++++++++++++++---- packages/blitz-dom/src/svg/construct.rs | 15 ++++- packages/blitz-paint/src/render/svg.rs | 43 ++++++++----- tests/blitz-tests/tests/svg_group_opacity.rs | 20 +++++- tests/blitz-tests/tests/svg_native.rs | 60 +++++++++++++++++- 6 files changed, 172 insertions(+), 32 deletions(-) diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 0ea9337f7b..b77184520b 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -853,7 +853,7 @@ impl<'a> TElement for BlitzNode<'a> { #[cfg(feature = "svg-native")] if elem.name.ns == markup5ever::ns!(svg) { for attr in elem.attrs() { - if let Some(mut source_decl) = crate::svg::attrs::svg_presentation_hint( + for mut source_decl in crate::svg::attrs::svg_presentation_hint( &attr.name.local, &attr.value, &SVG_URL_EXTRA_DATA, diff --git a/packages/blitz-dom/src/svg/attrs.rs b/packages/blitz-dom/src/svg/attrs.rs index ffe382cbeb..a2328e8971 100644 --- a/packages/blitz-dom/src/svg/attrs.rs +++ b/packages/blitz-dom/src/svg/attrs.rs @@ -89,9 +89,9 @@ pub fn svg_presentation_hint( local: &LocalName, value: &str, url_extra_data: &UrlExtraData, -) -> Option { +) -> Vec { if !is_plausible_presentation_attr(local) { - return None; + return Vec::new(); } // SVG presentation attributes accept unitless numbers (user units, which @@ -109,20 +109,60 @@ pub fn svg_presentation_hint( Default::default(), ); - let property_id = PropertyId::parse(local.as_ref(), &context).ok()?; + // The `transform` *attribute* uses SVG's transform-list grammar (space/comma-flexible, + // `rotate(angle, cx, cy)`/`skewX`/`skewY` with an implicit (0,0) origin), which isn't + // valid CSS `transform` syntax and would simply fail `PropertyDeclaration::parse_into` + // below. Flatten it with the SVG-aware list parser instead and re-express the result as + // an equivalent `matrix()` + `transform-origin: 0 0` -- both valid CSS syntax -- so it + // still lands in the cascade at presentation-hint priority and is correctly overridden + // by `style="transform: ..."` / stylesheet rules, same as every other presentation attr. + // Two separate declarations (not one shared buffer): `parse_into` asserts its output + // buffer starts empty, so `transform` and `transform-origin` each need their own. + if local.as_ref() == "transform" { + let affine = super::geometry::parse_transform_list(value); + if affine == kurbo::Affine::IDENTITY { + return Vec::new(); + } + let [a, b, c, d, e, f] = affine.as_coeffs(); + let mut transform_decl = SourcePropertyDeclaration::default(); + let mut origin_decl = SourcePropertyDeclaration::default(); + let (Some(()), Some(())) = ( + parse_declaration( + &context, + "transform", + &format!("matrix({a}, {b}, {c}, {d}, {e}, {f})"), + &mut transform_decl, + ), + parse_declaration(&context, "transform-origin", "0 0", &mut origin_decl), + ) else { + return Vec::new(); + }; + return vec![transform_decl, origin_decl]; + } let mut source_property_declaration = SourcePropertyDeclaration::default(); - let mut input = ParserInput::new(value); - let mut parser = style::values::Parser::new(&mut input); - PropertyDeclaration::parse_into( - &mut source_property_declaration, - property_id, + match parse_declaration( &context, - &mut parser, - ) - .ok()?; + local.as_ref(), + value, + &mut source_property_declaration, + ) { + Some(()) => vec![source_property_declaration], + None => Vec::new(), + } +} - Some(source_property_declaration) +fn parse_declaration( + context: &ParserContext, + local: &str, + value: &str, + out: &mut SourcePropertyDeclaration, +) -> Option<()> { + let property_id = PropertyId::parse(local, context).ok()?; + let mut input = ParserInput::new(value); + let mut parser = style::values::Parser::new(&mut input); + PropertyDeclaration::parse_into(out, property_id, context, &mut parser).ok()?; + Some(()) } /// Read a "group C" attribute directly off the element, bypassing diff --git a/packages/blitz-dom/src/svg/construct.rs b/packages/blitz-dom/src/svg/construct.rs index cc74c37c5d..88e8ee7d9c 100644 --- a/packages/blitz-dom/src/svg/construct.rs +++ b/packages/blitz-dom/src/svg/construct.rs @@ -11,14 +11,17 @@ use std::collections::HashMap; use blitz_traits::node_id::NodeId; +use euclid::{Point2D, Rect as EuclidRect, Size2D}; use kurbo::{Affine, Rect, Shape, Size}; use parley::{FontContext, LayoutContext}; use style::Atom; +use style::values::computed::CSSPixelLength; use style::values::specified::box_::{DisplayInside, DisplayOutside}; use crate::BaseDocument; use crate::layout::damage::CONSTRUCT_SVG; use crate::node::{Attribute, SpecialElementData, TextBrush}; +use crate::resolve_2d_transform; use super::attrs::raw_attr; use super::context::{SvgContext, SvgNode, SvgNodeKind}; @@ -225,8 +228,16 @@ fn walk( *budget -= 1; - let local_transform = raw_attr(elem.attrs(), "transform") - .map(geometry::parse_transform_list) + // Read the *computed* `transform`, not the raw attribute: presentation attributes are synthesized into the cascade + // at the lowest priority so this picks up `style="transform: ..."` / stylesheet overrides for free, and is what + // makes CSS-authored transforms on inner SVG elements take effect at all. + let zero_reference_box = EuclidRect::new( + Point2D::new(CSSPixelLength::new(0.0), CSSPixelLength::new(0.0)), + Size2D::new(CSSPixelLength::new(0.0), CSSPixelLength::new(0.0)), + ); + let local_transform = node + .primary_styles() + .and_then(|s| resolve_2d_transform(s.get_box(), zero_reference_box)) .unwrap_or(Affine::IDENTITY); let ctm = parent_ctm * local_transform; diff --git a/packages/blitz-paint/src/render/svg.rs b/packages/blitz-paint/src/render/svg.rs index 6e1444360f..db15b5148d 100644 --- a/packages/blitz-paint/src/render/svg.rs +++ b/packages/blitz-paint/src/render/svg.rs @@ -29,7 +29,8 @@ impl ElementCx<'_, '_> { let viewport = ctx.viewport; // Clip source for a *group's* opacity layer: groups have no accumulated bounding box, so clip to // the fragment's own viewport in base space instead. Always finite, never over-tight enough to clip real content. - let fragment_clip = base * Rect::new(0.0, 0.0, viewport.width, viewport.height).to_path(0.1); + let fragment_clip = + base * Rect::new(0.0, 0.0, viewport.width, viewport.height).to_path(0.1); let mut stack: Vec = Vec::new(); @@ -55,9 +56,9 @@ impl ElementCx<'_, '_> { node.kind, SvgNodeKind::Shape(_) | SvgNodeKind::Text(_) | SvgNodeKind::ForeignObject ); - let visible = style.as_ref().is_none_or(|s| { - s.get_inherited_box().visibility == StyloVisibility::Visible - }); + let visible = style + .as_ref() + .is_none_or(|s| s.get_inherited_box().visibility == StyloVisibility::Visible); // A hidden leaf paints nothing, so skip it outright. A hidden *group* still pushes a frame -- `visibility` // is inherited and a descendant may override it back to visible. @@ -65,11 +66,18 @@ impl ElementCx<'_, '_> { continue; } - let opacity = style.as_ref().map(|s| s.get_effects().opacity).unwrap_or(1.0); + let opacity = style + .as_ref() + .map(|s| s.get_effects().opacity) + .unwrap_or(1.0); let finite = ctm.is_finite() && node.bbox.is_finite(); let mut layer_open = false; - if finite && opacity < 1.0 && opacity > 0.0 { + if finite && opacity < 1.0 { + // `opacity <= 0.0` still opens a (zero-alpha) layer rather than being + // special-cased into a skip: a group's descendants are separate entries + // later in this flat pre-order list, so skipping only the group's own + // frame would leave them to paint at full opacity on their own iterations. let clip = if is_leaf { let stroke_pad = style .as_ref() @@ -79,16 +87,22 @@ impl ElementCx<'_, '_> { } else { fragment_clip.clone() }; - scene.push_layer(peniko::Mix::Normal, opacity, Affine::IDENTITY, &clip, None, None); + scene.push_layer( + peniko::Mix::Normal, + opacity.max(0.0), + Affine::IDENTITY, + &clip, + None, + None, + ); layer_open = true; - } else if finite && opacity <= 0.0 { - // Fully transparent. - stack.push(Frame { idx: i, layer_open: false }); - continue; } if !finite { - stack.push(Frame { idx: i, layer_open: false }); + stack.push(Frame { + idx: i, + layer_open: false, + }); continue; } @@ -310,9 +324,8 @@ mod tests { use style::values::computed::svg::SVGWidth; use style::values::generics::NonNegative; - let ten_percent = SVGWidth::LengthPercentage(NonNegative(LengthPercentage::new_percent( - Percentage(0.1), - ))); + let ten_percent = + SVGWidth::LengthPercentage(NonNegative(LengthPercentage::new_percent(Percentage(0.1)))); let viewport = kurbo::Size::new(100.0, 100.0); assert!((svg_length_px(&ten_percent, viewport) - 10.0).abs() < 1e-3); } diff --git a/tests/blitz-tests/tests/svg_group_opacity.rs b/tests/blitz-tests/tests/svg_group_opacity.rs index 0dfaffa4ed..31e418a5e3 100644 --- a/tests/blitz-tests/tests/svg_group_opacity.rs +++ b/tests/blitz-tests/tests/svg_group_opacity.rs @@ -121,6 +121,23 @@ fn overlapping_siblings_in_an_opacity_group_composite_as_one_unit() { ); } +#[test] +fn zero_opacity_group_hides_descendants() { + // Regression test: the flat per-node paint loop used to skip painting only the group's + // *own* frame on `opacity <= 0`, leaving descendants, separate entries later in the + // same flat list, to still paint at full opacity on their own iterations. + let px = center_pixel( + r##" + + + + + + "##, + ); + assert_eq!(px, [255, 255, 255]); +} + #[test] fn stroked_shape_at_partial_opacity_keeps_its_stroke() { // The opacity layer's clip must include the stroke's outset, not just the fill bbox. @@ -132,7 +149,8 @@ fn stroked_shape_at_partial_opacity_keeps_its_stroke() { "##, ); assert_ne!( - px, [255, 255, 255], + px, + [255, 255, 255], "the stroke should reach the canvas center even through a 0.5-opacity layer whose clip \ is derived from a zero-height fill bbox, but the pixel is untouched white" ); diff --git a/tests/blitz-tests/tests/svg_native.rs b/tests/blitz-tests/tests/svg_native.rs index b9379619d6..a185661a03 100644 --- a/tests/blitz-tests/tests/svg_native.rs +++ b/tests/blitz-tests/tests/svg_native.rs @@ -4,8 +4,11 @@ //! between the `usvg` and `svg-native` paths so they double as a parity check: the observable box size of a root //! `` must not change based on which rendering backend produced it. +use anyrender::render_to_buffer; +use anyrender_vello_cpu::VelloCpuImageRenderer; use blitz_dom::DocumentConfig; use blitz_html::{HtmlDocument, HtmlProvider}; +use blitz_paint::paint_scene; use blitz_traits::shell::{ColorScheme, Viewport}; use std::sync::Arc; @@ -94,6 +97,58 @@ fn deeply_nested_shapes_do_not_panic_construction_or_layout() { ); } +fn pixel_at(html: &str, x: u32, y: u32) -> [u8; 3] { + let mut doc = HtmlDocument::from_html( + html, + DocumentConfig { + viewport: Some(Viewport::new(100, 100, 1.0, ColorScheme::Light)), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + }, + ); + doc.resolve(0.0); + let buffer = render_to_buffer::( + |scene| paint_scene(scene, &mut doc, 1.0, 100, 100, 0, 0), + 100, + 100, + ); + let idx = ((y * 100 + x) * 4) as usize; + [buffer[idx], buffer[idx + 1], buffer[idx + 2]] +} + +#[test] +fn css_transform_on_inner_element_moves_it() { + // Regression test: fragment construction used to build each node's CTM from the raw `transform` + // *attribute* only, so a `transform` set via `style=`/stylesheet had no effect on painting. + let html = r##" + + + + "##; + assert_eq!( + pixel_at(html, 15, 15), + [255, 255, 255], + "original position should be vacated by the CSS transform" + ); + assert_eq!( + pixel_at(html, 65, 65), + [255, 0, 0], + "CSS transform should move the rect to the new position" + ); +} + +#[test] +fn svg_transform_attribute_still_applies_without_css() { + let html = r##" + + + + "##; + assert_eq!(pixel_at(html, 15, 15), [255, 255, 255]); + assert_eq!(pixel_at(html, 65, 65), [255, 0, 0]); +} + #[test] fn nested_svg_establishes_its_own_viewport() { use blitz_dom::svg::SvgNodeKind; @@ -204,7 +259,10 @@ fn bare_shape_with_no_fill_declared_anywhere_paints_black() { let rect_id = doc.query_selector("#r").unwrap().unwrap(); let style = doc.get_node(rect_id).unwrap().primary_styles().unwrap(); use style::values::computed::svg::SVGPaintKind; - assert!(matches!(style.get_inherited_svg().fill.kind, SVGPaintKind::Color(_))); + assert!(matches!( + style.get_inherited_svg().fill.kind, + SVGPaintKind::Color(_) + )); } #[test] From 37297e04b9756dd76b38fd28aec75437d255e8d1 Mon Sep 17 00:00:00 2001 From: Ritesh Date: Tue, 18 Aug 2026 19:23:13 +0000 Subject: [PATCH 5/5] Fixed BaseDocument IntrinsicSize --- packages/blitz-dom/src/layout/mod.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/blitz-dom/src/layout/mod.rs b/packages/blitz-dom/src/layout/mod.rs index 6cac14c7bf..cb14668997 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -279,19 +279,21 @@ impl BaseDocument { Some(vb) if vb.width() > 0.0 && vb.height() > 0.0 => { let ratio = (vb.width() / vb.height()) as f32; ( - taffy::Size { - width: 300.0, - height: 300.0 / ratio, + IntrinsicSizes { + width: Some(300.0), + height: Some(300.0 / ratio), + ratio: Some(ratio), }, - Some(ratio), + taffy::Size::ZERO, ) } _ => ( - taffy::Size { - width: 300.0, - height: 150.0, + IntrinsicSizes { + width: Some(300.0), + height: Some(150.0), + ratio: None, }, - None, + taffy::Size::ZERO, ), } }