Skip to main content

blitz_dom/layout/
svg_geometry.rs

1use std::collections::{HashMap, HashSet};
2
3use blitz_traits::events::HitResult;
4use kurbo::{Affine, Point, Rect};
5use usvg::svgtypes::{Align, AspectRatio, Length, LengthUnit, Transform};
6
7use crate::document::BoundingRect;
8use crate::{BaseDocument, Node, NodeId, local_name};
9
10const SVG_NAMESPACE: &str = "http://www.w3.org/2000/svg";
11
12pub(crate) fn is_svg(node: &Node) -> bool {
13    node.element_data()
14        .is_some_and(|element| element.name.ns.as_ref() == SVG_NAMESPACE)
15}
16
17fn is_graphics(node: &Node) -> bool {
18    is_svg(node)
19        && node.element_data().is_some_and(|element| {
20            matches!(
21                element.name.local.as_ref(),
22                "svg"
23                    | "g"
24                    | "a"
25                    | "path"
26                    | "rect"
27                    | "circle"
28                    | "ellipse"
29                    | "line"
30                    | "polyline"
31                    | "polygon"
32                    | "text"
33                    | "use"
34                    | "image"
35                    | "foreignObject"
36                    | "foreignobject"
37            )
38        })
39}
40
41fn attr<'a>(node: &'a Node, name: &str) -> Option<&'a str> {
42    node.element_data()?
43        .attrs()
44        .iter()
45        .find(|attribute| {
46            attribute.name.ns.as_ref().is_empty()
47                && attribute.name.local.as_ref().eq_ignore_ascii_case(name)
48        })
49        .map(|attribute| attribute.value.as_ref())
50}
51
52/// IDs exist only in the image source. Existing IDs and fragment references
53/// retain their values, and the live DOM is never mutated.
54pub(crate) fn serialization_ids(doc: &BaseDocument, root: NodeId) -> HashMap<NodeId, String> {
55    let mut used: HashSet<String> = doc
56        .nodes
57        .iter()
58        .filter_map(|(_, node)| node.attr(local_name!("id")))
59        .map(str::to_owned)
60        .collect();
61    let mut ids = HashMap::new();
62    let mut pending = vec![root];
63
64    while let Some(id) = pending.pop() {
65        let node = &doc.nodes[id];
66        if is_graphics(node) {
67            let source_id = match node.attr(local_name!("id")).filter(|id| !id.is_empty()) {
68                Some(id) => id.to_owned(),
69                None => {
70                    let mut generated =
71                        format!("blitz-svg-geometry-{}-{}", root.as_u64(), id.as_u64());
72                    while !used.insert(generated.clone()) {
73                        generated.push('_');
74                    }
75                    generated
76                }
77            };
78            ids.insert(id, source_id);
79        }
80        pending.extend(node.children.iter().rev().copied());
81    }
82
83    ids
84}
85
86fn rect(rect: usvg::Rect) -> Rect {
87    Rect::new(
88        f64::from(rect.x()),
89        f64::from(rect.y()),
90        f64::from(rect.right()),
91        f64::from(rect.bottom()),
92    )
93}
94
95fn bounding_rect(rect: Rect) -> BoundingRect {
96    BoundingRect {
97        x: rect.x0,
98        y: rect.y0,
99        width: rect.width(),
100        height: rect.height(),
101    }
102}
103
104fn union(bounds: &mut HashMap<NodeId, Rect>, id: NodeId, rect: Rect) {
105    bounds
106        .entry(id)
107        .and_modify(|old| *old = old.union(rect))
108        .or_insert(rect);
109}
110
111/// Immutable geometry travels with the parsed image. Only the viewport
112/// transform depends on current layout, so resizing and scrolling do not
113/// require parsing the SVG again.
114#[derive(Debug)]
115pub(crate) struct SvgGeometry {
116    bounds: HashMap<NodeId, Rect>,
117    bboxes: HashMap<NodeId, Rect>,
118    hits: Vec<(NodeId, Rect)>,
119    view_box: Option<Rect>,
120    aspect: AspectRatio,
121    user_to_tree: Affine,
122}
123
124impl SvgGeometry {
125    pub(crate) fn build(
126        doc: &BaseDocument,
127        root: NodeId,
128        tree: &usvg::Tree,
129        ids: &HashMap<NodeId, String>,
130    ) -> Self {
131        let view_box = tree.intrinsic_dimensions().view_box.map(|view_box| {
132            Rect::new(
133                f64::from(view_box.x()),
134                f64::from(view_box.y()),
135                f64::from(view_box.x() + view_box.width()),
136                f64::from(view_box.y() + view_box.height()),
137            )
138        });
139        let aspect = attr(&doc.nodes[root], "preserveAspectRatio")
140            .and_then(|value| value.parse().ok())
141            .unwrap_or_default();
142        let user_to_tree = view_box
143            .map(|view_box| {
144                viewport_transform(
145                    view_box,
146                    aspect,
147                    f64::from(tree.size().width()),
148                    f64::from(tree.size().height()),
149                )
150            })
151            .unwrap_or(Affine::IDENTITY);
152        let mut geometry = Self {
153            bounds: HashMap::new(),
154            bboxes: HashMap::new(),
155            hits: Vec::new(),
156            view_box,
157            aspect,
158            user_to_tree,
159        };
160        let by_id: HashMap<&str, NodeId> = ids
161            .iter()
162            .map(|(id, source_id)| (source_id.as_str(), *id))
163            .collect();
164        let mut hit_bounds = HashMap::new();
165
166        for child in tree.root().children() {
167            geometry.collect(doc, root, child, &by_id, None, false, &mut hit_bounds);
168        }
169        geometry.bboxes.insert(
170            root,
171            user_to_tree
172                .inverse()
173                .transform_rect_bbox(rect(tree.root().abs_bounding_box())),
174        );
175
176        // usvg does not render foreignObject contents. Its SVG viewport box
177        // still has geometry, without introducing a synthetic painted shape.
178        let mut pending = vec![root];
179        while let Some(id) = pending.pop() {
180            let node = &doc.nodes[id];
181            if is_svg(node)
182                && node.element_data().is_some_and(|element| {
183                    element
184                        .name
185                        .local
186                        .as_ref()
187                        .eq_ignore_ascii_case("foreignObject")
188                })
189                && let Some(box_) = foreign_object_box(node, view_box, tree.size())
190            {
191                geometry.bboxes.insert(id, box_);
192                let transform = user_to_tree * element_transform(doc, root, id);
193                let painted = transform.transform_rect_bbox(box_);
194                geometry.record(doc, root, id, painted, &mut hit_bounds);
195            }
196
197            // DOM order is paint order for the authored SVG elements.
198            // Definition instances are attributed to their <use>, not to the
199            // original definition's DOM nodes.
200            if let Some(bounds) = hit_bounds.get(&id) {
201                geometry.hits.push((id, *bounds));
202            }
203            pending.extend(node.children.iter().rev().copied());
204        }
205
206        geometry
207    }
208
209    fn collect(
210        &mut self,
211        doc: &BaseDocument,
212        root: NodeId,
213        node: &usvg::Node,
214        ids: &HashMap<&str, NodeId>,
215        inherited_owner: Option<NodeId>,
216        in_use: bool,
217        hit_bounds: &mut HashMap<NodeId, Rect>,
218    ) {
219        let mapped = (!in_use).then(|| ids.get(node.id()).copied()).flatten();
220        let owner = mapped.or(inherited_owner);
221
222        if let Some(id) = mapped {
223            union(&mut self.bboxes, id, rect(node.bounding_box()));
224        }
225
226        match node {
227            usvg::Node::Group(group) => {
228                if group.opacity().get() == 0.0 {
229                    return;
230                }
231                let in_use = in_use
232                    || mapped.is_some_and(|id| {
233                        doc.nodes[id]
234                            .element_data()
235                            .is_some_and(|element| element.name.local == local_name!("use"))
236                    });
237                for child in group.children() {
238                    self.collect(doc, root, child, ids, owner, in_use, hit_bounds);
239                }
240            }
241            usvg::Node::Path(path) if !path.is_visible() => {}
242            usvg::Node::Image(image) if !image.is_visible() => {}
243            _ => {
244                if let Some(owner) = owner {
245                    self.record(
246                        doc,
247                        root,
248                        owner,
249                        rect(node.abs_stroke_bounding_box()),
250                        hit_bounds,
251                    );
252                }
253            }
254        }
255    }
256
257    fn record(
258        &mut self,
259        doc: &BaseDocument,
260        root: NodeId,
261        owner: NodeId,
262        bounds: Rect,
263        hit_bounds: &mut HashMap<NodeId, Rect>,
264    ) {
265        if !bounds.is_finite() || bounds.width() <= 0.0 || bounds.height() <= 0.0 {
266            return;
267        }
268        union(hit_bounds, owner, bounds);
269        let mut current = Some(owner);
270        while let Some(id) = current {
271            let node = &doc.nodes[id];
272            if is_graphics(node) {
273                union(&mut self.bounds, id, bounds);
274            }
275            if id == root {
276                break;
277            }
278            current = node.parent;
279        }
280    }
281
282    fn content_transform(&self, width: f64, height: f64) -> Affine {
283        self.view_box
284            .map(|view_box| {
285                viewport_transform(view_box, self.aspect, width, height)
286                    * self.user_to_tree.inverse()
287            })
288            .unwrap_or(Affine::IDENTITY)
289    }
290}
291
292fn viewport_transform(view_box: Rect, aspect: AspectRatio, width: f64, height: f64) -> Affine {
293    let mut sx = width / view_box.width();
294    let mut sy = height / view_box.height();
295    if aspect.align != Align::None {
296        let scale = if aspect.slice { sx.max(sy) } else { sx.min(sy) };
297        sx = scale;
298        sy = scale;
299    }
300    let horizontal = match aspect.align {
301        Align::XMidYMin | Align::XMidYMid | Align::XMidYMax => 0.5,
302        Align::XMaxYMin | Align::XMaxYMid | Align::XMaxYMax => 1.0,
303        _ => 0.0,
304    };
305    let vertical = match aspect.align {
306        Align::XMinYMid | Align::XMidYMid | Align::XMaxYMid => 0.5,
307        Align::XMinYMax | Align::XMidYMax | Align::XMaxYMax => 1.0,
308        _ => 0.0,
309    };
310    Affine::translate((
311        (width - view_box.width() * sx) * horizontal,
312        (height - view_box.height() * sy) * vertical,
313    )) * Affine::scale_non_uniform(sx, sy)
314        * Affine::translate((-view_box.x0, -view_box.y0))
315}
316
317fn element_transform(doc: &BaseDocument, root: NodeId, id: NodeId) -> Affine {
318    let mut transform = Affine::IDENTITY;
319    let mut current = Some(id);
320    while let Some(id) = current {
321        if id == root {
322            break;
323        }
324        let node = &doc.nodes[id];
325        if let Some(value) = attr(node, "transform")
326            && let Ok(value) = value.parse::<Transform>()
327        {
328            transform =
329                Affine::new([value.a, value.b, value.c, value.d, value.e, value.f]) * transform;
330        }
331        current = node.parent;
332    }
333    transform
334}
335
336fn foreign_object_box(node: &Node, view_box: Option<Rect>, size: usvg::Size) -> Option<Rect> {
337    let width = view_box.map_or(f64::from(size.width()), |rect| rect.width());
338    let height = view_box.map_or(f64::from(size.height()), |rect| rect.height());
339    let length = |name, reference, default| {
340        let Some(value) = attr(node, name) else {
341            return Some(default);
342        };
343        let value = value.parse::<Length>().ok()?;
344        let factor = match value.unit {
345            LengthUnit::None | LengthUnit::Px => 1.0,
346            LengthUnit::Percent => reference / 100.0,
347            LengthUnit::In => 96.0,
348            LengthUnit::Cm => 96.0 / 2.54,
349            LengthUnit::Mm => 96.0 / 25.4,
350            LengthUnit::Pt => 96.0 / 72.0,
351            LengthUnit::Pc => 16.0,
352            _ => return None,
353        };
354        Some(value.number * factor)
355    };
356    let x = length("x", width, 0.0)?;
357    let y = length("y", height, 0.0)?;
358    let width = length("width", width, 0.0)?;
359    let height = length("height", height, 0.0)?;
360    (width > 0.0 && height > 0.0).then(|| Rect::new(x, y, x + width, y + height))
361}
362
363impl Node {
364    /// Map parsed SVG canvas coordinates into this root's CSS border box.
365    /// Paint uses this same transform, scaled to device pixels.
366    pub fn svg_content_transform(&self) -> Option<Affine> {
367        let geometry = self.element_data()?.svg_geometry.as_ref()?;
368        let layout = self.final_layout();
369        let left = f64::from(layout.border.left + layout.padding.left);
370        let top = f64::from(layout.border.top + layout.padding.top);
371        let width = f64::from(layout.size.width)
372            - left
373            - f64::from(layout.border.right + layout.padding.right);
374        let height = f64::from(layout.size.height)
375            - top
376            - f64::from(layout.border.bottom + layout.padding.bottom);
377        if width <= 0.0 || height <= 0.0 {
378            return None;
379        }
380        Some(Affine::translate((left, top)) * geometry.content_transform(width, height))
381    }
382
383    pub(crate) fn hit_inline_svg(
384        &self,
385        x: f32,
386        y: f32,
387        hits: &mut Option<Vec<HitResult>>,
388    ) -> Option<HitResult> {
389        use style::computed_values::pointer_events::T as PointerEvents;
390        use style::computed_values::visibility::T as Visibility;
391
392        let geometry = self.element_data()?.svg_geometry.as_ref()?;
393        let transform = self.svg_content_transform()?;
394        let point = transform.inverse() * Point::new(f64::from(x), f64::from(y));
395        for (id, bounds) in geometry.hits.iter().rev() {
396            if !bounds.contains(point) {
397                continue;
398            }
399            let node = self.tree().get(*id)?;
400            if node.is_display_none()
401                || node.primary_styles().is_some_and(|style| {
402                    style.clone_pointer_events() == PointerEvents::None
403                        || matches!(
404                            style.clone_visibility(),
405                            Visibility::Hidden | Visibility::Collapse
406                        )
407                })
408            {
409                continue;
410            }
411            let bounds = transform.transform_rect_bbox(*bounds);
412            let hit = HitResult {
413                node_id: *id,
414                x: x - bounds.x0 as f32,
415                y: y - bounds.y0 as f32,
416                is_text: false,
417            };
418            if let Some(hits) = hits.as_mut() {
419                hits.push(hit);
420            } else {
421                return Some(hit);
422            }
423        }
424        None
425    }
426}
427
428impl BaseDocument {
429    pub(crate) fn svg_geometry_root(&self, id: NodeId) -> Option<NodeId> {
430        let node = self.get_node(id)?;
431        if !is_svg(node) {
432            return None;
433        }
434        let mut current = node.parent;
435        while let Some(id) = current {
436            let node = self.get_node(id)?;
437            if node
438                .element_data()
439                .is_some_and(|element| element.svg_geometry.is_some())
440            {
441                return Some(id);
442            }
443            current = node.parent;
444        }
445        None
446    }
447
448    pub(crate) fn svg_client_rect(&self, root: NodeId, id: NodeId) -> Option<BoundingRect> {
449        let root_node = self.get_node(root)?;
450        let geometry = root_node.element_data()?.svg_geometry.as_ref()?;
451        let bounds = *geometry.bounds.get(&id)?;
452        let mut current = Some(id);
453        while let Some(id) = current {
454            let node = self.get_node(id)?;
455            if node.is_element() && node.is_display_none() {
456                return None;
457            }
458            current = node.parent;
459        }
460        let bounds = root_node
461            .svg_content_transform()?
462            .transform_rect_bbox(bounds);
463        let origin = root_node.absolute_position(0.0, 0.0);
464        let bounds = bounding_rect(
465            bounds
466                + kurbo::Vec2::new(
467                    f64::from(origin.x) - self.viewport_scroll.x,
468                    f64::from(origin.y) - self.viewport_scroll.y,
469                ),
470        );
471        Some(self.transformed_client_rect(root, bounds))
472    }
473
474    pub(crate) fn svg_user_bbox(&self, id: NodeId) -> Option<BoundingRect> {
475        let node = self.get_node(id)?;
476        if !is_graphics(node) {
477            return None;
478        }
479        let root = if node
480            .element_data()
481            .is_some_and(|element| element.svg_geometry.is_some())
482        {
483            Some(id)
484        } else {
485            self.svg_geometry_root(id)
486        };
487        let bounds = root
488            .and_then(|root| self.get_node(root))
489            .and_then(Node::element_data)
490            .and_then(|element| element.svg_geometry.as_ref())
491            .and_then(|geometry| geometry.bboxes.get(&id))
492            .copied()
493            .unwrap_or(Rect::ZERO);
494        Some(bounding_rect(bounds))
495    }
496}