diff --git a/lib/src/converter/visit.rs b/lib/src/converter/visit.rs index 443e392..9a84ffe 100644 --- a/lib/src/converter/visit.rs +++ b/lib/src/converter/visit.rs @@ -45,19 +45,59 @@ fn should_render_node(node: Node) -> bool { && !matches!(node.tag_name().name(), DEFS_TAG_NAME | MARKER_TAG_NAME | SYMBOL_TAG_NAME) } +/// Resolve `href` or `xlink:href` on a `` element to a document node. +/// Only fragment references (`#id`) within the same document are supported. +fn resolve_use_href<'a, 'input: 'a>( + doc: &'a Document<'input>, + node: Node<'a, 'input>, +) -> Option> { + let href = node + .attribute("href") + .or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href")))?; + let id = href.strip_prefix('#')?; + doc.root() + .descendants() + .find(|n| n.attribute("id") == Some(id)) +} + pub fn depth_first_visit(doc: &Document, visitor: &mut impl XmlVisitor) { - fn visit_node(node: Node, visitor: &mut impl XmlVisitor) { + fn visit_node(doc: &Document, node: Node, visitor: &mut V) { if !should_render_node(node) { return; } visitor.visit_enter(node); - node.children().for_each(|child| visit_node(child, visitor)); + if node.tag_name().name() == USE_TAG_NAME + && let Some(referenced) = resolve_use_href(doc, node) + { + visit_use_referenced_node(doc, referenced, visitor); + } else { + node.children() + .for_each(|child| visit_node(doc, child, visitor)); + } + visitor.visit_exit(node); + } + + /// Special-cased [visit_node] for a node referenced by a `` element to get + /// around the [`should_render_node`] filter that usually prevents symbols from being rendered. + fn visit_use_referenced_node(doc: &Document, node: Node, visitor: &mut V) { + if !node.is_element() { + return; + } + if node + .attribute("style") + .is_some_and(|s| s.contains("display:none")) + { + return; + } + visitor.visit_enter(node); + node.children() + .for_each(|child| visit_node(doc, child, visitor)); visitor.visit_exit(node); } doc.root() .children() - .for_each(|child| visit_node(child, visitor)); + .for_each(|child| visit_node(doc, child, visitor)); } impl<'a, T: Turtle> XmlVisitor for ConversionVisitor<'a, T> { @@ -165,6 +205,50 @@ impl<'a, T: Turtle> XmlVisitor for ConversionVisitor<'a, T> { 0., -(viewport_size[1] + viewport_pos[1].unwrap_or(0.)), )); + } else if node.has_tag_name(USE_TAG_NAME) { + // Per SVG spec, x/y translate is appended to the element's transform + // https://www.w3.org/TR/SVG2/struct.html#UseLayout + let x = self.length_attr_to_user_units(&node, "x").unwrap_or(0.); + let y = self.length_attr_to_user_units(&node, "y").unwrap_or(0.); + flattened_transform = flattened_transform.then(&Transform2D::translation(x, y)); + } else if node.has_tag_name(SYMBOL_TAG_NAME) { + let view_box = node + .attribute("viewBox") + .map(ViewBox::from_str) + .transpose() + .expect("could not parse viewBox on symbol") + .filter(|view_box| { + if view_box.w <= 0. || view_box.h <= 0. { + warn!("Invalid viewBox: {view_box:?}"); + false + } else { + true + } + }); + let preserve_aspect_ratio = node.attribute("preserveAspectRatio").map(|attr| { + AspectRatio::from_str(attr).expect("could not parse preserveAspectRatio") + }); + // Viewport size: symbol's own width/height, or fallback to viewBox dims, or parent viewport + let viewport_size = match ( + self.length_attr_to_user_units(&node, "width"), + self.length_attr_to_user_units(&node, "height"), + &view_box, + ) { + (Some(w), Some(h), _) => [w, h], + (_, _, Some(vb)) => [vb.w, vb.h], + _ => *self.viewport_dim_stack.last().unwrap_or(&[1., 1.]), + }; + self.viewport_dim_stack.push(viewport_size); + if let Some(view_box) = view_box { + let viewport_transform = get_viewport_transform( + view_box, + preserve_aspect_ratio, + viewport_size, + [None, None], + ); + flattened_transform = flattened_transform.then(&viewport_transform); + // Does not need Y-axis translation unlike , already in g-code coords space. + } } else if node.has_attribute("viewBox") { warn!("View box is not supported on a {}", node.tag_name().name()); } @@ -360,11 +444,8 @@ impl<'a, T: Turtle> XmlVisitor for ConversionVisitor<'a, T> { } } } - USE_TAG_NAME => { - warn!("Unsupported node: {node:?}"); - } // No-op tags - SVG_TAG_NAME | GROUP_TAG_NAME => {} + SVG_TAG_NAME | GROUP_TAG_NAME | USE_TAG_NAME | SYMBOL_TAG_NAME => {} _ => { debug!("Unknown node: {}", node.tag_name().name()); } @@ -377,7 +458,7 @@ impl<'a, T: Turtle> XmlVisitor for ConversionVisitor<'a, T> { fn visit_exit(&mut self, node: Node) { self.terrarium.pop_transform(); self.name_stack.pop(); - if node.tag_name().name() == SVG_TAG_NAME { + if matches!(node.tag_name().name(), SVG_TAG_NAME | SYMBOL_TAG_NAME) { self.viewport_dim_stack.pop(); } } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 5a49691..d420170 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -291,6 +291,42 @@ mod test { assert_close(actual, expected) } + #[test] + fn use_defs_produces_expected_gcode() { + let svg = include_str!("../tests/use_defs.svg"); + let expected = g_code::parse::file_parser(include_str!("../tests/use_defs.gcode")) + .unwrap() + .iter_emit_tokens() + .collect::>(); + let actual = get_actual(svg, false, [None; 2]); + + assert_close(actual, expected) + } + + #[test] + fn use_xlink_href_produces_expected_gcode() { + let svg = include_str!("../tests/use_xlink_href.svg"); + let expected = g_code::parse::file_parser(include_str!("../tests/use_xlink_href.gcode")) + .unwrap() + .iter_emit_tokens() + .collect::>(); + let actual = get_actual(svg, false, [None; 2]); + + assert_close(actual, expected) + } + + #[test] + fn use_symbol_produces_expected_gcode() { + let svg = include_str!("../tests/use_symbol.svg"); + let expected = g_code::parse::file_parser(include_str!("../tests/use_symbol.gcode")) + .unwrap() + .iter_emit_tokens() + .collect::>(); + let actual = get_actual(svg, false, [None; 2]); + + assert_close(actual, expected) + } + #[test] #[cfg(feature = "serde")] fn deserialize_v1_config_succeeds() { diff --git a/lib/tests/use_defs.gcode b/lib/tests/use_defs.gcode new file mode 100644 index 0000000..86ba63e --- /dev/null +++ b/lib/tests/use_defs.gcode @@ -0,0 +1,7 @@ +G21 +G90;svg > use > path#square +G0 X1 Y9 +G1 X9 Y9 F300 +G1 X9 Y1 F300 +G1 X1 Y1 F300 +G1 X1 Y9 F300 diff --git a/lib/tests/use_defs.svg b/lib/tests/use_defs.svg new file mode 100644 index 0000000..7c1929f --- /dev/null +++ b/lib/tests/use_defs.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/tests/use_symbol.gcode b/lib/tests/use_symbol.gcode new file mode 100644 index 0000000..c6191a8 --- /dev/null +++ b/lib/tests/use_symbol.gcode @@ -0,0 +1,4 @@ +G21 +G90;svg > use > symbol#sym > path +G0 X1 Y9 +G1 X3 Y9 F300 diff --git a/lib/tests/use_symbol.svg b/lib/tests/use_symbol.svg new file mode 100644 index 0000000..2de460c --- /dev/null +++ b/lib/tests/use_symbol.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/tests/use_xlink_href.gcode b/lib/tests/use_xlink_href.gcode new file mode 100644 index 0000000..86ba63e --- /dev/null +++ b/lib/tests/use_xlink_href.gcode @@ -0,0 +1,7 @@ +G21 +G90;svg > use > path#square +G0 X1 Y9 +G1 X9 Y9 F300 +G1 X9 Y1 F300 +G1 X1 Y1 F300 +G1 X1 Y9 F300 diff --git a/lib/tests/use_xlink_href.svg b/lib/tests/use_xlink_href.svg new file mode 100644 index 0000000..b6d59f5 --- /dev/null +++ b/lib/tests/use_xlink_href.svg @@ -0,0 +1,6 @@ + + + + + +