Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ dioxus-native = { workspace = true, features = [
"vello-hybrid",
"floats",
"svg",
"svg-native",
"prelude",
] }
euclid = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions packages/blitz-dom/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ default = [
]
custom-widget = ["dep:anyrender", "accessibility"]
tracing = ["dep:tracing"]
# First-party inline <svg> 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"]
Expand Down
43 changes: 43 additions & 0 deletions packages/blitz-dom/assets/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; `<defs>`/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;
}

30 changes: 28 additions & 2 deletions packages/blitz-dom/src/accessibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 13 additions & 5 deletions packages/blitz-dom/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ pub struct BaseDocument {
/// Nodes that contain custom widgets
#[cfg(feature = "custom-widget")]
pub(crate) custom_widget_nodes: HashSet<NodeId>,
/// Root `<svg>` 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<NodeId>,
/// Rendering resources allocated by custom widgets that should be deallocated during the next render
#[cfg(feature = "custom-widget")]
pub(crate) pending_resource_deallocations: Vec<anyrender::ResourceId>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
}
Expand All @@ -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());
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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());
}
Expand Down
48 changes: 47 additions & 1 deletion packages/blitz-dom/src/layout/construct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,53 @@ fn collect_layout_children_with_wrap(
}
}

#[cfg(feature = "svg")]
// First-party inline SVG (`svg-native`): the root `<svg>` 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();

Expand Down
38 changes: 36 additions & 2 deletions packages/blitz-dom/src/layout/damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg>` root doesn't force a `usvg`-style whole-subtree
/// HTML box reconstruction, and so sibling `<svg>` 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 `<svg>` 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(
Expand Down
34 changes: 34 additions & 0 deletions packages/blitz-dom/src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,40 @@ impl BaseDocument {
}
ImageData::None => (IntrinsicSizes::default(), taffy::Size::ZERO),
},
// `svg-native`: intrinsic size/ratio for a root `<svg>` 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 => {
Expand Down
2 changes: 2 additions & 0 deletions packages/blitz-dom/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions packages/blitz-dom/src/mutator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1036,6 +1046,8 @@ impl<'doc> DocumentMutator<'doc> {
SpecialElementData::CheckboxInput(_) => {}
#[cfg(feature = "file-input")]
SpecialElementData::FileInput(_) => {}
#[cfg(feature = "svg-native")]
SpecialElementData::SvgRoot(_) => {}
SpecialElementData::None => {}
}
});
Expand Down
Loading
Loading