Skip to main content

oxihuman_export/
svg_path_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! SVG path data export (M/L/C/Z commands).
6
7/// A single SVG path command.
8#[allow(dead_code)]
9#[derive(Debug, Clone)]
10pub enum SvgPathCmd {
11    MoveTo(f32, f32),
12    LineTo(f32, f32),
13    CubicBezier(f32, f32, f32, f32, f32, f32),
14    ClosePath,
15}
16
17/// An SVG path element (collection of commands + style).
18#[allow(dead_code)]
19pub struct SvgPathElement {
20    pub commands: Vec<SvgPathCmd>,
21    pub stroke: String,
22    pub stroke_width: f32,
23    pub fill: String,
24}
25
26/// Create a new empty path element.
27#[allow(dead_code)]
28pub fn new_svg_path(stroke: &str, stroke_width: f32, fill: &str) -> SvgPathElement {
29    SvgPathElement {
30        commands: Vec::new(),
31        stroke: stroke.to_string(),
32        stroke_width,
33        fill: fill.to_string(),
34    }
35}
36
37/// Append a MoveTo command.
38#[allow(dead_code)]
39pub fn move_to(path: &mut SvgPathElement, x: f32, y: f32) {
40    path.commands.push(SvgPathCmd::MoveTo(x, y));
41}
42
43/// Append a LineTo command.
44#[allow(dead_code)]
45pub fn line_to(path: &mut SvgPathElement, x: f32, y: f32) {
46    path.commands.push(SvgPathCmd::LineTo(x, y));
47}
48
49/// Append a cubic Bezier command.
50#[allow(dead_code)]
51pub fn cubic_to(path: &mut SvgPathElement, cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32) {
52    path.commands
53        .push(SvgPathCmd::CubicBezier(cx1, cy1, cx2, cy2, x, y));
54}
55
56/// Append a ClosePath command.
57#[allow(dead_code)]
58pub fn close_path(path: &mut SvgPathElement) {
59    path.commands.push(SvgPathCmd::ClosePath);
60}
61
62/// Serialize commands to SVG path `d` attribute string.
63#[allow(dead_code)]
64pub fn commands_to_d(commands: &[SvgPathCmd]) -> String {
65    commands
66        .iter()
67        .map(|cmd| match cmd {
68            SvgPathCmd::MoveTo(x, y) => format!("M {:.4} {:.4}", x, y),
69            SvgPathCmd::LineTo(x, y) => format!("L {:.4} {:.4}", x, y),
70            SvgPathCmd::CubicBezier(cx1, cy1, cx2, cy2, x, y) => format!(
71                "C {:.4} {:.4} {:.4} {:.4} {:.4} {:.4}",
72                cx1, cy1, cx2, cy2, x, y
73            ),
74            SvgPathCmd::ClosePath => "Z".to_string(),
75        })
76        .collect::<Vec<_>>()
77        .join(" ")
78}
79
80/// Serialize an SVG path element to an `<path .../>` tag.
81#[allow(dead_code)]
82pub fn path_to_svg_tag(path: &SvgPathElement) -> String {
83    let d = commands_to_d(&path.commands);
84    format!(
85        "<path d=\"{}\" stroke=\"{}\" stroke-width=\"{}\" fill=\"{}\"/>",
86        d, path.stroke, path.stroke_width, path.fill
87    )
88}
89
90/// Wrap path tags in a minimal SVG document.
91#[allow(dead_code)]
92pub fn wrap_svg(width: f32, height: f32, body: &str) -> String {
93    format!(
94        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">{}</svg>",
95        width, height, body
96    )
97}
98
99/// Count commands in a path.
100#[allow(dead_code)]
101pub fn command_count(path: &SvgPathElement) -> usize {
102    path.commands.len()
103}
104
105/// Check if path starts with a MoveTo.
106#[allow(dead_code)]
107pub fn starts_with_move(path: &SvgPathElement) -> bool {
108    path.commands
109        .first()
110        .is_some_and(|c| matches!(c, SvgPathCmd::MoveTo(..)))
111}
112
113/// Convert a polyline to a path.
114#[allow(dead_code)]
115pub fn polyline_to_path(points: &[[f32; 2]], stroke: &str, stroke_width: f32) -> SvgPathElement {
116    let mut path = new_svg_path(stroke, stroke_width, "none");
117    for (i, &[x, y]) in points.iter().enumerate() {
118        if i == 0 {
119            move_to(&mut path, x, y);
120        } else {
121            line_to(&mut path, x, y);
122        }
123    }
124    path
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn move_to_adds_command() {
133        let mut p = new_svg_path("black", 1.0, "none");
134        move_to(&mut p, 10.0, 20.0);
135        assert_eq!(command_count(&p), 1);
136    }
137
138    #[test]
139    fn commands_to_d_move() {
140        let d = commands_to_d(&[SvgPathCmd::MoveTo(1.0, 2.0)]);
141        assert!(d.contains('M'));
142    }
143
144    #[test]
145    fn commands_to_d_line() {
146        let d = commands_to_d(&[SvgPathCmd::LineTo(3.0, 4.0)]);
147        assert!(d.contains('L'));
148    }
149
150    #[test]
151    fn commands_to_d_close() {
152        let d = commands_to_d(&[SvgPathCmd::ClosePath]);
153        assert!(d.contains('Z'));
154    }
155
156    #[test]
157    fn commands_to_d_cubic() {
158        let d = commands_to_d(&[SvgPathCmd::CubicBezier(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)]);
159        assert!(d.contains('C'));
160    }
161
162    #[test]
163    fn path_tag_has_stroke() {
164        let mut p = new_svg_path("red", 2.0, "none");
165        move_to(&mut p, 0.0, 0.0);
166        let tag = path_to_svg_tag(&p);
167        assert!(tag.contains("red"));
168    }
169
170    #[test]
171    fn wrap_svg_has_namespace() {
172        let svg = wrap_svg(100.0, 200.0, "");
173        assert!(svg.contains("xmlns"));
174    }
175
176    #[test]
177    fn starts_with_move_true() {
178        let mut p = new_svg_path("black", 1.0, "none");
179        move_to(&mut p, 0.0, 0.0);
180        assert!(starts_with_move(&p));
181    }
182
183    #[test]
184    fn starts_with_move_false_empty() {
185        let p = new_svg_path("black", 1.0, "none");
186        assert!(!starts_with_move(&p));
187    }
188
189    #[test]
190    fn polyline_to_path_count() {
191        let pts = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]];
192        let p = polyline_to_path(&pts, "blue", 1.0);
193        assert_eq!(command_count(&p), 3);
194    }
195}