diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fe81be150..5cb286df45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,47 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev - 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="system-fonts," + 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 3a62b548cd..7b06c0f915 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,6 +1095,7 @@ dependencies = [ "dioxus-core", "dioxus-native-dom", "keyboard-types 0.7.0", + "kurbo", "markup5ever", "stylo", "taffy", diff --git a/Cargo.toml b/Cargo.toml index 6aad7b72b9..c6aab47539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -268,6 +268,7 @@ dioxus-native = { workspace = true, features = [ "vello-hybrid", "floats", "svg", + "svg-native", "prelude", ] } euclid = { workspace = true } diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index ab86c3b719..f50c7429fc 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -21,6 +21,8 @@ default = [ ] custom-widget = ["dep:anyrender", "accessibility"] tracing = ["dep:tracing"] +# First-party inline rendering. Default off; additive-only. +svg-native = [] svg = ["dep:usvg", "dep:svgtypes"] # WOFF decoding (using the "wuff" crate which is pure Rust) woff = ["dep:wuff"] diff --git a/packages/blitz-dom/assets/default.css b/packages/blitz-dom/assets/default.css index 01918fea81..afa0255a0c 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; @@ -1126,3 +1127,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 1b239c5a7f..f3641ba2c4 100644 --- a/packages/blitz-dom/src/accessibility.rs +++ b/packages/blitz-dom/src/accessibility.rs @@ -57,8 +57,34 @@ impl BaseDocument { let name = element_data.name.local.to_string(); let role_attr = element_data.attr(local_name!("role")); - // TODO: The roles of elements with strong native semantics cannot be overridden; see - // https://www.w3.org/TR/wai-aria-1.2/#host_general_conflict. + // 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); + } + + // TODO: The roles of elements with strong native semantics cannot be overridden; + // see https://www.w3.org/TR/wai-aria-1.2/#host_general_conflict. let role = role_attr .and_then(role_from_name) .or_else(|| role_from_element_data(element_data)) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 4543788bd0..f404ed99ea 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -322,6 +322,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, @@ -479,6 +485,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(), @@ -1714,7 +1722,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, ElementState::ACTIVE, |node| node.active()); } @@ -1729,7 +1737,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, ElementState::ACTIVE, |node| node.unactive()); } @@ -1863,8 +1871,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) @@ -1898,7 +1906,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, ElementState::HOVER, |node| node.unhover()); } diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 106eab3f34..73a471f253 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -533,7 +533,53 @@ fn collect_layout_children_with_wrap( } } - #[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.svg_root_nodes.insert(container_node_id); + 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 b352ffbefb..cbb00cef74 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -29,8 +29,42 @@ 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( + RestyleDamage::RELAYOUT.bits() + | 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 4b8e659641..82a9947836 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -268,6 +268,40 @@ impl BaseDocument { } ImageData::None => (IntrinsicSizes::default(), taffy::Size::ZERO), }, + // `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; + ( + IntrinsicSizes { + width: Some(300.0), + height: Some(300.0 / ratio), + ratio: Some(ratio), + }, + taffy::Size::ZERO, + ) + } + _ => ( + IntrinsicSizes { + width: Some(300.0), + height: Some(150.0), + ratio: None, + }, + taffy::Size::ZERO, + ), + } + } + // 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 + // the 300x150 default object size but have no intrinsic ratio. SpecialElementData::Canvas(_) | SpecialElementData::SubDocument(_) | SpecialElementData::None => { diff --git a/packages/blitz-dom/src/lib.rs b/packages/blitz-dom/src/lib.rs index 04382e6c06..9e1b885481 100644 --- a/packages/blitz-dom/src/lib.rs +++ b/packages/blitz-dom/src/lib.rs @@ -65,6 +65,8 @@ mod stylo_device; mod stylo_to_cursor_icon; mod stylo_to_kurbo; mod stylo_to_parley; +#[cfg(feature = "svg-native")] +pub mod svg; pub 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 fef8ccf12f..052b34872a 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -307,6 +307,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 { @@ -1036,6 +1046,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 ccce2e186a..72cdef5509 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -353,6 +353,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 @@ -375,6 +380,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")] @@ -554,6 +561,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), @@ -891,6 +906,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 3a31abf239..53a39c8c9f 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1452,6 +1452,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 c89b73609c..80bf4ab397 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -393,6 +393,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 21a4c8eacf..039933ddfa 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -59,6 +59,17 @@ 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. +#[cfg(feature = "svg-native")] +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); @@ -423,7 +434,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 { @@ -646,7 +657,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..... @@ -654,9 +665,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>> { @@ -880,6 +890,26 @@ 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) { + for attr in elem.attrs() { + for mut source_decl in 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(), + )); + } + } + } + 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..a2328e8971 --- /dev/null +++ b/packages/blitz-dom/src/svg/attrs.rs @@ -0,0 +1,215 @@ +//! 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, +) -> Vec { + if !is_plausible_presentation_attr(local) { + return Vec::new(); + } + + // 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(), + ); + + // 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(); + match parse_declaration( + &context, + local.as_ref(), + value, + &mut source_property_declaration, + ) { + Some(()) => vec![source_property_declaration], + None => Vec::new(), + } +} + +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 +/// 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..88e8ee7d9c --- /dev/null +++ b/packages/blitz-dom/src/svg/construct.rs @@ -0,0 +1,565 @@ +//! 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 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}; +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. +/// +/// 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(); + 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(_)) { + 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() { + 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; + + // 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; + + 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, + }; + let inner_viewport = Size::new(use_w, use_h); + for &child in doc.nodes[target].children.iter() { + walk( + doc, + child, + inner_ctm, + Some(idx), + id_map, + inner_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..64035e282a --- /dev/null +++ b/packages/blitz-dom/src/svg/hit_test.rs @@ -0,0 +1,149 @@ +//! 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 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, + } +} + +/// 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 { + use style::computed_values::pointer_events::T as PointerEvents; + + 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 Some(style) = dom_node.primary_styles() else { + continue; + }; + + if style.get_inherited_box().visibility != StyloVisibility::Visible { + continue; + } + if style.clone_pointer_events() == PointerEvents::None { + continue; + } + + 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 = stroke_width_px(&svg.stroke_width, ctx.viewport); + 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::*; + 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 none_paint_is_never_painted() { + let none = SVGPaint { + kind: SVGPaintKind::None, + fallback: SVGPaintFallback::Unset, + }; + assert!(!is_painted(&none)); + } + + #[test] + fn color_paint_is_painted() { + assert!(is_painted(&color_paint())); + } + + #[test] + fn fill_initial_value_is_opaque_black_and_painted() { + assert!(is_painted(&SVGPaint::BLACK)); + } + + #[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-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/Cargo.toml b/packages/blitz-paint/Cargo.toml index 2e9569b698..e0d81edb70 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 f6855c57f6..2c9238b13d 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::cell::RefCell; use std::collections::HashMap; @@ -544,7 +546,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); @@ -636,6 +640,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(), @@ -681,7 +687,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, @@ -1029,6 +1038,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..db15b5148d --- /dev/null +++ b/packages/blitz-paint/src/render/svg.rs @@ -0,0 +1,356 @@ +//! Paint a first-party inline `` fragment. + +use anyrender::PaintScene; +use blitz_dom::svg::SvgNodeKind; +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 { + return; + }; + + 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); + + let mut stack: Vec + + + + + + "##, + ); + // 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 + ); +} + +#[test] +fn overlapping_siblings_in_an_opacity_group_composite_as_one_unit() { + // A `` must open a single layer around its whole subtree, so overlapping + // siblings blend with the *background* as a unit, not with each other first. + let grouped = center_pixel( + r##" + + + + + + + "##, + ); + 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 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. + 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 new file mode 100644 index 0000000000..a185661a03 --- /dev/null +++ b/tests/blitz-tests/tests/svg_native.rs @@ -0,0 +1,348 @@ +//! 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 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 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 + + + "##, + ); +} + +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; + 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" + ); +} + +#[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" + ); +}