Skip to main content

latexml_post/
svg.rs

1//! SVG rendering processor.
2//!
3//! Port of `LaTeXML::Post::SVG` (522 lines of Perl).
4//! Converts `ltx:picture` elements to SVG by traversing LaTeXML picture
5//! primitives (g, path, line, rect, circle, ellipse, polygon, bezier,
6//! arc, wedge, text, dots) and generating corresponding SVG elements.
7//!
8//! The coordinate system is mirrored: LaTeXML uses a bottom-left origin
9//! with y increasing upward; SVG uses top-left with y increasing downward.
10//! The top-level picture gets `transform="translate(0,h) scale(1,-1)"`.
11
12use std::f64::consts::PI;
13
14use libxml::tree::Node;
15use rustc_hash::FxHashMap as HashMap;
16
17use crate::{
18  document::{NodeData, PostDocument, element_children_iter},
19  processor::{ProcessResult, Processor},
20};
21
22const SVG_URI: &str = "http://www.w3.org/2000/svg";
23const DPI: f64 = 96.0;
24
25/// SVG post-processor.
26///
27/// Port of `LaTeXML::Post::SVG`.
28pub struct SVG {
29  name: String,
30}
31
32impl Default for SVG {
33  fn default() -> Self { Self::new() }
34}
35
36impl SVG {
37  pub fn new() -> Self { SVG { name: "SVG".to_string() } }
38
39  /// Convert a single ltx:picture node to SVG.
40  ///
41  /// Port of `SVG::ProcessSVG`.
42  fn process_svg(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
43    let h = node
44      .get_attribute("height")
45      .map(|s| to_px(&s))
46      .unwrap_or(0.0);
47
48    // Build a group with the y-flip transform
49    let children: Vec<NodeData> = element_children_iter(node)
50      .filter_map(|child| self.convert_node(doc, &child))
51      .collect();
52
53    let g_transform = format!("translate(0,{:.2}) scale(1,-1)", h);
54    let g = NodeData::Element {
55      tag: "svg:g".to_string(),
56      attributes: Some(HashMap::from_iter([("transform".to_string(), g_transform)])),
57      children,
58    };
59
60    // Build the outer svg:svg element
61    let width = node.get_attribute("width").map(|s| to_px(&s));
62    let height = node.get_attribute("height").map(|s| to_px(&s));
63    let clip = node.get_attribute("clip").as_deref() == Some("true");
64
65    let mut svg_attrs = HashMap::default();
66    svg_attrs.insert("version".to_string(), "1.1".to_string());
67    if let Some(w) = width {
68      svg_attrs.insert("width".to_string(), format!("{:.2}", w));
69    }
70    if let Some(h) = height {
71      svg_attrs.insert("height".to_string(), format!("{:.2}", h));
72    }
73    if !clip {
74      svg_attrs.insert("overflow".to_string(), "visible".to_string());
75    }
76
77    Some(NodeData::Element {
78      tag:        "svg:svg".to_string(),
79      attributes: Some(svg_attrs),
80      children:   vec![g],
81    })
82  }
83
84  /// Dispatch conversion of a single element.
85  ///
86  /// Port of `convertNode` + converter dispatch table.
87  fn convert_node(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
88    let tag = doc.get_qname(node)?;
89    match tag.as_str() {
90      "ltx:picture" => self.convert_picture(doc, node),
91      "ltx:g" => self.convert_g(doc, node),
92      "ltx:path" => self.convert_path(doc, node),
93      "ltx:line" => self.convert_line(doc, node),
94      "ltx:polygon" => self.convert_polygon(doc, node),
95      "ltx:rect" => self.convert_rect(doc, node),
96      "ltx:circle" => self.convert_circle(doc, node),
97      "ltx:ellipse" => self.convert_ellipse(doc, node),
98      "ltx:bezier" => self.convert_bezier(doc, node),
99      "ltx:arc" => self.convert_arc(doc, node),
100      "ltx:wedge" => self.convert_wedge(doc, node),
101      "ltx:dots" => self.convert_dots(doc, node),
102      "ltx:text" => self.convert_text(doc, node),
103      _ => {
104        // Foreign element: wrap in svg:foreignObject
105        self.convert_foreign(doc, node)
106      },
107    }
108  }
109
110  fn convert_picture(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
111    let h = node
112      .get_attribute("height")
113      .map(|s| to_px(&s))
114      .unwrap_or(0.0);
115    let children = self.convert_children(doc, node);
116    Some(NodeData::Element {
117      tag: "svg:g".to_string(),
118      attributes: Some(HashMap::from_iter([(
119        "transform".to_string(),
120        format!("translate(0,{:.2}) scale(1,-1)", h),
121      )])),
122      children,
123    })
124  }
125
126  fn convert_g(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
127    let mut attrs = self.copy_valid_attrs(node);
128    // Check for framed+fillframe
129    if node.get_attribute("framed").as_deref() == Some("true")
130      && node.get_attribute("fillframe").as_deref() == Some("true")
131    {
132      let fill = node
133        .get_attribute("fill")
134        .unwrap_or_else(|| "white".to_string());
135      attrs.insert(
136        "filter".to_string(),
137        format!("url(#bg{})", fill.replace('#', "")),
138      );
139    }
140    let children = self.convert_children(doc, node);
141    Some(NodeData::Element {
142      tag: "svg:g".to_string(),
143      attributes: if attrs.is_empty() { None } else { Some(attrs) },
144      children,
145    })
146  }
147
148  fn convert_path(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
149    let attrs = self.copy_valid_attrs(node);
150    let children = self.convert_children(doc, node);
151    Some(NodeData::Element {
152      tag: "svg:path".to_string(),
153      attributes: if attrs.is_empty() { None } else { Some(attrs) },
154      children,
155    })
156  }
157
158  fn convert_line(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
159    let mut attrs = self.copy_valid_attrs(node);
160    if let Some(points) = node.get_attribute("points") {
161      attrs.insert("d".to_string(), format!("M {}", points));
162    }
163    let children = self.convert_children(doc, node);
164    Some(NodeData::Element {
165      tag: "svg:path".to_string(),
166      attributes: Some(attrs),
167      children,
168    })
169  }
170
171  fn convert_polygon(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
172    let mut attrs = self.copy_valid_attrs(node);
173    if let Some(points) = node.get_attribute("points") {
174      attrs.insert("d".to_string(), format!("M {} z", points));
175    }
176    let children = self.convert_children(doc, node);
177    Some(NodeData::Element {
178      tag: "svg:path".to_string(),
179      attributes: Some(attrs),
180      children,
181    })
182  }
183
184  fn convert_rect(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
185    let attrs = self.copy_valid_attrs(node);
186    if let Some(part) = node.get_attribute("part") {
187      // Partial rect (rounded corner subset) → path
188      let x = parse_dim(&node.get_attribute("x").unwrap_or_default());
189      let y = parse_dim(&node.get_attribute("y").unwrap_or_default());
190      let w = parse_dim(&node.get_attribute("width").unwrap_or_default());
191      let h = parse_dim(&node.get_attribute("height").unwrap_or_default());
192      let rx = parse_dim(&node.get_attribute("rx").unwrap_or_default());
193      let d = oval_path(&part, x, y, w, h, rx);
194      let mut path_attrs = attrs;
195      path_attrs.insert("d".to_string(), d);
196      Some(NodeData::Element {
197        tag:        "svg:path".to_string(),
198        attributes: Some(path_attrs),
199        children:   self.convert_children(doc, node),
200      })
201    } else {
202      Some(NodeData::Element {
203        tag:        "svg:rect".to_string(),
204        attributes: if attrs.is_empty() { None } else { Some(attrs) },
205        children:   self.convert_children(doc, node),
206      })
207    }
208  }
209
210  fn convert_circle(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
211    let mut attrs = self.copy_valid_attrs(node);
212    if let Some(x) = node.get_attribute("x") {
213      attrs.insert("cx".to_string(), x);
214    }
215    if let Some(y) = node.get_attribute("y") {
216      attrs.insert("cy".to_string(), y);
217    }
218    Some(NodeData::Element {
219      tag:        "svg:circle".to_string(),
220      attributes: Some(attrs),
221      children:   self.convert_children(doc, node),
222    })
223  }
224
225  fn convert_ellipse(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
226    let mut attrs = self.copy_valid_attrs(node);
227    if let Some(x) = node.get_attribute("x") {
228      attrs.insert("cx".to_string(), x);
229    }
230    if let Some(y) = node.get_attribute("y") {
231      attrs.insert("cy".to_string(), y);
232    }
233    Some(NodeData::Element {
234      tag:        "svg:ellipse".to_string(),
235      attributes: Some(attrs),
236      children:   self.convert_children(doc, node),
237    })
238  }
239
240  fn convert_bezier(&self, doc: &PostDocument, node: &Node) -> Option<NodeData> {
241    let mut attrs = self.copy_valid_attrs(node);
242    if let Some(points) = node.get_attribute("points") {
243      let coords: Vec<f64> = explode_coord(&points);
244      let n = coords.len() / 2;
245      if n >= 2 {
246        let x0 = coords[0];
247        let y0 = coords[1];
248        let cmd = match n {
249          4 => "C",
250          3 => "Q",
251          _ => "T",
252        };
253        let rest: String = coords[2..]
254          .chunks(2)
255          .map(|p| format!("{:.2},{:.2}", p[0], p.get(1).unwrap_or(&0.0)))
256          .collect::<Vec<_>>()
257          .join(" ");
258        attrs.insert(
259          "d".to_string(),
260          format!("M {:.2},{:.2} {} {}", x0, y0, cmd, rest),
261        );
262      }
263    }
264    if node.get_attribute("displayedpoints").is_some() {
265      attrs.insert("stroke-dasharray".to_string(), "2".to_string());
266    }
267    Some(NodeData::Element {
268      tag:        "svg:path".to_string(),
269      attributes: Some(attrs),
270      children:   self.convert_children(doc, node),
271    })
272  }
273
274  fn convert_arc(&self, _doc: &PostDocument, node: &Node) -> Option<NodeData> {
275    let x = parse_dim(&node.get_attribute("x").unwrap_or_default());
276    let y = parse_dim(&node.get_attribute("y").unwrap_or_default());
277    let r = parse_dim(&node.get_attribute("r").unwrap_or_default());
278    let a1 = parse_dim(&node.get_attribute("angle1").unwrap_or_default());
279    let a2 = parse_dim(&node.get_attribute("angle2").unwrap_or_default());
280
281    let mut bb = a2 - a1;
282    if bb < 0.0 {
283      bb += 360.0;
284    }
285    let large_arc = if bb > 180.0 { 1 } else { 0 };
286
287    let a1r = a1 * PI / 180.0;
288    let a2r = a2 * PI / 180.0;
289    let x1 = x + r * a1r.cos();
290    let y1 = y + r * a1r.sin();
291    let x2 = x + r * a2r.cos();
292    let y2 = y + r * a2r.sin();
293
294    let d = format!(
295      "M {:.2} {:.2} A {:.2} {:.2} 0 {} 1 {:.2} {:.2}",
296      x1, y1, r, r, large_arc, x2, y2
297    );
298
299    Some(NodeData::Element {
300      tag:        "svg:path".to_string(),
301      attributes: Some(HashMap::from_iter([("d".to_string(), d)])),
302      children:   vec![],
303    })
304  }
305
306  fn convert_wedge(&self, _doc: &PostDocument, node: &Node) -> Option<NodeData> {
307    let x = parse_dim(&node.get_attribute("x").unwrap_or_default());
308    let y = parse_dim(&node.get_attribute("y").unwrap_or_default());
309    let r = parse_dim(&node.get_attribute("r").unwrap_or_default());
310    let a1 = parse_dim(&node.get_attribute("angle1").unwrap_or_default());
311    let a2 = parse_dim(&node.get_attribute("angle2").unwrap_or_default());
312
313    let mut bb = a2 - a1;
314    if bb < 0.0 {
315      bb += 360.0;
316    }
317    let large_arc = if bb > 180.0 { 1 } else { 0 };
318
319    let a1r = a1 * PI / 180.0;
320    let a2r = a2 * PI / 180.0;
321    let x1 = x + r * a1r.cos();
322    let y1 = y + r * a1r.sin();
323    let x2 = x + r * a2r.cos();
324    let y2 = y + r * a2r.sin();
325
326    let d = format!(
327      "M {:.2} {:.2} L {:.2} {:.2} A {:.2} {:.2} 0 {} 1 {:.2} {:.2} z",
328      x, y, x1, y1, r, r, large_arc, x2, y2
329    );
330
331    let attrs = self.copy_valid_attrs(node);
332    let mut all_attrs = attrs;
333    all_attrs.insert("d".to_string(), d);
334
335    Some(NodeData::Element {
336      tag:        "svg:path".to_string(),
337      attributes: Some(all_attrs),
338      children:   vec![],
339    })
340  }
341
342  fn convert_dots(&self, _doc: &PostDocument, node: &Node) -> Option<NodeData> {
343    let points = node.get_attribute("points").unwrap_or_default();
344    let coords = explode_coord(&points);
345    let dotsize = node
346      .get_attribute("dotsize")
347      .unwrap_or_else(|| "2".to_string());
348
349    let mut circles = Vec::new();
350    for chunk in coords.chunks(2) {
351      if chunk.len() == 2 {
352        circles.push(NodeData::Element {
353          tag:        "svg:circle".to_string(),
354          attributes: Some(HashMap::from_iter([
355            ("cx".to_string(), format!("{:.2}", chunk[0])),
356            ("cy".to_string(), format!("{:.2}", chunk[1])),
357            ("r".to_string(), dotsize.clone()),
358          ])),
359          children:   vec![],
360        });
361      }
362    }
363
364    Some(NodeData::Element {
365      tag:        "svg:g".to_string(),
366      attributes: None,
367      children:   circles,
368    })
369  }
370
371  fn convert_text(&self, _doc: &PostDocument, node: &Node) -> Option<NodeData> {
372    let x = node.get_attribute("x").unwrap_or_else(|| "0".to_string());
373    let y = node.get_attribute("y").unwrap_or_else(|| "0".to_string());
374    let text = node.get_content();
375
376    let mut attrs = HashMap::default();
377    attrs.insert("x".to_string(), x);
378    attrs.insert("y".to_string(), y);
379    // Text needs to be un-flipped
380    attrs.insert("transform".to_string(), "scale(1,-1)".to_string());
381
382    // Font attributes
383    if let Some(fontsize) = node.get_attribute("fontsize") {
384      attrs.insert("font-size".to_string(), fontsize);
385    }
386    if let Some(font) = node.get_attribute("font") {
387      if font.contains("italic") {
388        attrs.insert("font-style".to_string(), "italic".to_string());
389      } else if font.contains("slanted") {
390        attrs.insert("font-style".to_string(), "oblique".to_string());
391      } else if font.contains("bold") {
392        attrs.insert("font-weight".to_string(), "bold".to_string());
393      } else if font.contains("smallcaps") {
394        attrs.insert("font-variant".to_string(), "small-caps".to_string());
395      }
396    }
397    if let Some(fill) = node.get_attribute("fill") {
398      attrs.insert("fill".to_string(), fill);
399    }
400
401    Some(NodeData::Element {
402      tag:        "svg:text".to_string(),
403      attributes: Some(attrs),
404      children:   vec![NodeData::Text(text)],
405    })
406  }
407
408  /// Wrap foreign (non-picture) elements in svg:foreignObject.
409  fn convert_foreign(&self, _doc: &PostDocument, node: &Node) -> Option<NodeData> {
410    let width = node
411      .get_attribute("width")
412      .or_else(|| node.get_attribute("imagewidth"))
413      .unwrap_or_else(|| "1pt".to_string());
414    let height = node
415      .get_attribute("height")
416      .or_else(|| node.get_attribute("imageheight"))
417      .unwrap_or_else(|| "1pt".to_string());
418    let depth = node
419      .get_attribute("depth")
420      .unwrap_or_else(|| "0pt".to_string());
421
422    let h_px = to_px(&height);
423    let d_px = to_px(&depth);
424    let y = h_px + d_px;
425
426    let fo = NodeData::Element {
427      tag:        "svg:foreignObject".to_string(),
428      attributes: Some(HashMap::from_iter([
429        ("width".to_string(), format!("{:.2}", to_px(&width))),
430        ("height".to_string(), format!("{:.2}", h_px)),
431        ("overflow".to_string(), "visible".to_string()),
432      ])),
433      children:   vec![NodeData::XmlNode(node.clone())],
434    };
435
436    Some(NodeData::Element {
437      tag:        "svg:g".to_string(),
438      attributes: Some(HashMap::from_iter([(
439        "transform".to_string(),
440        format!("translate(0,{:.2}) scale(1,-1)", y),
441      )])),
442      children:   vec![fo],
443    })
444  }
445
446  /// Convert all element children of a node.
447  fn convert_children(&self, doc: &PostDocument, node: &Node) -> Vec<NodeData> {
448    element_children_iter(node)
449      .filter_map(|child| self.convert_node(doc, &child))
450      .collect()
451  }
452
453  /// Copy valid SVG attributes from a LaTeXML node.
454  fn copy_valid_attrs(&self, node: &Node) -> HashMap<String, String> {
455    let mut attrs = HashMap::default();
456    let props = node.get_properties();
457    for (key, value) in &props {
458      match key.as_str() {
459        "d" | "r" | "rx" | "ry" | "x" | "y" | "width" | "height" | "cx" | "cy" | "x1" | "y1"
460        | "x2" | "y2" | "fill" | "stroke" | "stroke-width" | "stroke-dasharray"
461        | "stroke-linecap" | "stroke-linejoin" | "opacity" | "fill-opacity" | "stroke-opacity"
462        | "transform" | "style" | "class" => {
463          attrs.insert(key.clone(), value.clone());
464        },
465        "xml:id" => {},                // Skip: IDs handled separately
466        k if k.starts_with('_') => {}, // Skip internal attributes
467        _ => {},
468      }
469    }
470    attrs
471  }
472}
473
474impl Processor for SVG {
475  fn get_name(&self) -> &str { &self.name }
476
477  fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
478    doc.findnodes("//ltx:picture[child::*[not(local-name()='svg')]]")
479  }
480
481  fn process(&mut self, mut doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
482    if !nodes.is_empty() {
483      doc.add_namespace("svg", SVG_URI);
484    }
485    for node in &nodes {
486      if let Some(svg) = self.process_svg(&doc, node) {
487        let node_mut = node.clone();
488        doc.replace_node(&node_mut, &[svg]);
489      }
490    }
491    Ok(vec![doc])
492  }
493}
494
495// ======================================================================
496// Utility functions
497
498/// Convert a TeX dimension string to SVG pixels.
499///
500/// Port of `to_px`.
501fn to_px(s: &str) -> f64 {
502  let trimmed = s.trim();
503  if let Some(pt) = trimmed.strip_suffix("pt") {
504    pt.trim().parse::<f64>().unwrap_or(0.0) * DPI / 72.27
505  } else if let Some(px) = trimmed.strip_suffix("px") {
506    px.trim().parse::<f64>().unwrap_or(0.0)
507  } else if let Some(em) = trimmed.strip_suffix("em") {
508    em.trim().parse::<f64>().unwrap_or(0.0) * 10.0 // rough
509  } else {
510    trimmed.parse::<f64>().unwrap_or(0.0)
511  }
512}
513
514/// Parse a dimension string to a float.
515fn parse_dim(s: &str) -> f64 {
516  let trimmed = s.trim();
517  let num: String = trimmed
518    .chars()
519    .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+')
520    .collect();
521  num.parse::<f64>().unwrap_or(0.0)
522}
523
524/// Explode a space/comma-separated coordinate string into floats.
525fn explode_coord(s: &str) -> Vec<f64> {
526  s.split([' ', ','])
527    .filter(|s| !s.is_empty())
528    .filter_map(|s| s.trim().parse::<f64>().ok())
529    .collect()
530}
531
532/// Generate an SVG path for a partial rounded rectangle.
533///
534/// Port of `ovalPath`.
535fn oval_path(part: &str, x: f64, y: f64, w: f64, h: f64, r: f64) -> String {
536  match part {
537    "t" => format!(
538      "M {} {} L {} {} A {} {} 0 0 1 {} {} L {} {} A {} {} 0 0 1 {} {} L {} {}",
539      x,
540      y - h / 2.0,
541      x,
542      y - r,
543      r,
544      r,
545      x + r,
546      y,
547      x + w - r,
548      y,
549      r,
550      r,
551      x + w,
552      y - r,
553      x + w,
554      y - h / 2.0
555    ),
556    "b" => format!(
557      "M {} {} L {} {} A {} {} 0 0 1 {} {} L {} {} A {} {} 0 0 1 {} {} L {} {}",
558      x + w,
559      y - h / 2.0,
560      x + w,
561      y - h + r,
562      r,
563      r,
564      x + w - r,
565      y - h,
566      x + r,
567      y - h,
568      r,
569      r,
570      x,
571      y - h + r,
572      x,
573      y - h / 2.0
574    ),
575    _ => format!("M {} {} L {} {}", x, y, x + w, y), // fallback
576  }
577}
578
579#[cfg(test)]
580mod tests {
581  use super::*;
582
583  fn approx(a: f64, b: f64) -> bool { (a - b).abs() < 1e-6 }
584
585  #[test]
586  fn to_px_pt_scales_by_dpi_over_72_27() {
587    // 72.27pt = 1 inch = DPI pixels.
588    assert!(approx(to_px("72.27pt"), DPI));
589    assert!(approx(to_px("0pt"), 0.0));
590  }
591
592  #[test]
593  fn to_px_px_is_identity() {
594    assert!(approx(to_px("42px"), 42.0));
595    assert!(approx(to_px("  3.5px "), 3.5));
596  }
597
598  #[test]
599  fn to_px_em_rough_x10() {
600    // Rough approximation per the code comment; lock it in.
601    assert!(approx(to_px("2em"), 20.0));
602  }
603
604  #[test]
605  fn to_px_bare_number_is_f64() {
606    assert!(approx(to_px("7"), 7.0));
607    assert!(approx(to_px("  -1.5 "), -1.5));
608  }
609
610  #[test]
611  fn to_px_unknown_unit_is_zero() {
612    // No known suffix and no leading number → parse fails → 0.0.
613    assert!(approx(to_px("xyz"), 0.0));
614  }
615
616  #[test]
617  fn parse_dim_takes_leading_numeric_prefix() {
618    assert!(approx(parse_dim("12.5pt"), 12.5));
619    assert!(approx(parse_dim("-3.0em"), -3.0));
620    assert!(approx(parse_dim("+7px"), 7.0));
621  }
622
623  #[test]
624  fn parse_dim_no_leading_number_is_zero() {
625    assert!(approx(parse_dim("pt"), 0.0));
626    assert!(approx(parse_dim(""), 0.0));
627  }
628
629  #[test]
630  fn explode_coord_splits_on_space_and_comma() {
631    assert_eq!(explode_coord("1 2 3"), vec![1.0, 2.0, 3.0]);
632    assert_eq!(explode_coord("1,2,3"), vec![1.0, 2.0, 3.0]);
633    assert_eq!(explode_coord("1, 2 3"), vec![1.0, 2.0, 3.0]);
634  }
635
636  #[test]
637  fn explode_coord_skips_empty_and_non_numeric() {
638    // Empty tokens are filtered; non-numeric tokens are filtered by `parse.ok()`.
639    assert_eq!(explode_coord(",, 4  5,,"), vec![4.0, 5.0]);
640    assert_eq!(explode_coord("1 abc 2"), vec![1.0, 2.0]);
641  }
642
643  #[test]
644  fn explode_coord_empty_input_is_empty() {
645    assert!(explode_coord("").is_empty());
646    assert!(explode_coord("   ").is_empty());
647  }
648
649  #[test]
650  fn oval_path_top_starts_and_ends_at_half_height() {
651    // For the 't' branch the path begins at (x, y-h/2) and ends at (x+w, y-h/2).
652    let s = oval_path("t", 10.0, 20.0, 100.0, 40.0, 5.0);
653    assert!(s.starts_with("M 10 0 ")); // y - h/2 = 20 - 20 = 0
654    assert!(s.ends_with(" 110 0")); // x+w, y-h/2
655  }
656
657  #[test]
658  fn oval_path_bottom_traces_reverse_direction() {
659    // For the 'b' branch the path begins at (x+w, y-h/2) and ends at (x, y-h/2).
660    let s = oval_path("b", 10.0, 20.0, 100.0, 40.0, 5.0);
661    assert!(s.starts_with("M 110 0 "));
662    assert!(s.ends_with(" 10 0"));
663  }
664
665  #[test]
666  fn oval_path_unknown_part_uses_simple_line_fallback() {
667    let s = oval_path("?", 0.0, 0.0, 50.0, 10.0, 2.0);
668    assert_eq!(s, "M 0 0 L 50 0");
669  }
670}