Skip to main content

document_svg/transform/
mod.rs

1//! SVG Transform: Post-processing and tailoring of SVG documents (minify, monochrome, responsive).
2
3use quick_xml::Reader;
4use quick_xml::Writer;
5use quick_xml::events::{BytesStart, Event};
6use std::borrow::Cow;
7use std::io::{Cursor, Write};
8
9use crate::error::{Error, Result};
10
11#[derive(Clone, Debug, Default)]
12pub struct TransformOptions {
13    pub minify: bool,
14    pub monochrome: Option<String>,
15    pub responsive: bool,
16    pub precision: Option<usize>,
17    pub remove_metadata: bool,
18    pub clean_paths: bool,
19    pub strip_empty_groups: bool,
20}
21
22pub fn transform_svg(svg_bytes: &[u8], options: &TransformOptions) -> Result<Vec<u8>> {
23    let svg_str = std::str::from_utf8(svg_bytes)
24        .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
25
26    let mut reader = Reader::from_str(svg_str);
27    if options.minify {
28        reader.config_mut().trim_text(true);
29    }
30
31    let mut out = Cursor::new(Vec::with_capacity(svg_bytes.len()));
32    let mut writer = Writer::new(&mut out);
33
34    let mut width_val: Option<String> = None;
35    let mut height_val: Option<String> = None;
36    let mut viewbox_val: Option<String> = None;
37    let mut in_style_tag = false;
38    let mut skip_depth = 0usize;
39
40    #[derive(Clone)]
41    struct PendingGroup {
42        elem: BytesStart<'static>,
43        written: bool,
44    }
45    let mut group_stack: Vec<PendingGroup> = Vec::new();
46
47    loop {
48        let event = match reader.read_event() {
49            Ok(Event::Eof) => break,
50            Ok(event) => event,
51            Err(err) => {
52                return Err(Error::InvalidInput(format!(
53                    "failed to parse SVG input: {err}"
54                )));
55            }
56        };
57
58        if skip_depth > 0 {
59            match event {
60                Event::Start(_) => skip_depth += 1,
61                Event::End(_) => skip_depth -= 1,
62                _ => {}
63            }
64            continue;
65        }
66
67        match event {
68            Event::Decl(_) if options.minify => {}
69            Event::DocType(_) if options.minify => {}
70            Event::Comment(_) if options.minify => {}
71            Event::Start(ref e)
72                if options.remove_metadata
73                    && (e.name().as_ref() == b"metadata" || e.name().as_ref() == b"desc") =>
74            {
75                skip_depth = 1;
76            }
77            Event::Empty(ref e)
78                if options.remove_metadata
79                    && (e.name().as_ref() == b"metadata" || e.name().as_ref() == b"desc") => {}
80            Event::Start(ref e) if e.name().as_ref() == b"svg" => {
81                let mut elem = BytesStart::new("svg");
82                for attr in e.attributes().flatten() {
83                    let key = attr.key.as_ref();
84                    let val = std::str::from_utf8(&attr.value).unwrap_or("");
85
86                    if options.remove_metadata && key.starts_with(b"data-") {
87                        continue;
88                    }
89
90                    if key == b"width" {
91                        width_val = Some(val.to_string());
92                    } else if key == b"height" {
93                        height_val = Some(val.to_string());
94                    } else if key == b"viewBox" {
95                        viewbox_val = Some(val.to_string());
96                    }
97
98                    if options.responsive && (key == b"width" || key == b"height") {
99                        continue;
100                    }
101
102                    if let (Some(prec), true) = (options.precision, is_numeric_attr(key)) {
103                        let rounded = round_numbers_in_str(val, prec);
104                        elem.push_attribute((key, rounded.as_bytes()));
105                        continue;
106                    }
107
108                    elem.push_attribute(attr);
109                }
110
111                if options.responsive && viewbox_val.is_none() {
112                    let dims = width_val.as_ref().zip(height_val.as_ref());
113                    if let Some((w, h)) = dims {
114                        let w_px = parse_svg_dimension_to_px(w);
115                        let h_px = parse_svg_dimension_to_px(h);
116                        if let (Some(wp), Some(hp)) = (w_px, h_px) {
117                            let synthesized = format!("0 0 {wp} {hp}");
118                            let final_viewbox = if let Some(prec) = options.precision {
119                                round_numbers_in_str(&synthesized, prec)
120                            } else {
121                                Cow::Borrowed(synthesized.as_str())
122                            };
123                            elem.push_attribute(("viewBox", final_viewbox.as_ref()));
124                        }
125                    }
126                }
127
128                writer.write_event(Event::Start(elem))?;
129            }
130            Event::Start(ref e) => {
131                let tag_name = e.name();
132                if tag_name.as_ref() == b"style" {
133                    in_style_tag = true;
134                }
135                if options.strip_empty_groups && tag_name.as_ref() == b"g" {
136                    // Create transformed element before pushing to stack
137                    let mut transformed_elem = BytesStart::new("g");
138                    for attr in e.attributes().flatten() {
139                        let key = attr.key.as_ref();
140                        let val = std::str::from_utf8(&attr.value).unwrap_or("");
141                        if options.remove_metadata && key.starts_with(b"data-") {
142                            continue;
143                        }
144                        if let Some(ref mono_color) = options.monochrome {
145                            if key == b"fill" {
146                                if val == "none" {
147                                    transformed_elem.push_attribute(("fill", "none"));
148                                } else {
149                                    transformed_elem.push_attribute(("fill", mono_color.as_str()));
150                                }
151                                continue;
152                            } else if key == b"stroke" {
153                                if val == "none" {
154                                    transformed_elem.push_attribute(("stroke", "none"));
155                                } else {
156                                    transformed_elem
157                                        .push_attribute(("stroke", mono_color.as_str()));
158                                }
159                                continue;
160                            } else if key == b"color" {
161                                transformed_elem.push_attribute(("color", mono_color.as_str()));
162                                continue;
163                            } else if key == b"style" {
164                                let transformed_style =
165                                    transform_style_for_monochrome(val, mono_color);
166                                transformed_elem
167                                    .push_attribute(("style", transformed_style.as_str()));
168                                continue;
169                            }
170                        }
171                        if let (Some(prec), true) = (options.precision, is_numeric_attr(key)) {
172                            let rounded = round_numbers_in_str(val, prec);
173                            transformed_elem.push_attribute((key, rounded.as_bytes()));
174                            continue;
175                        }
176                        transformed_elem.push_attribute(attr);
177                    }
178                    group_stack.push(PendingGroup {
179                        elem: transformed_elem.into_owned(),
180                        written: false,
181                    });
182                } else {
183                    for g in group_stack.iter_mut() {
184                        if !g.written {
185                            writer.write_event(Event::Start(g.elem.clone()))?;
186                            g.written = true;
187                        }
188                    }
189                    write_transformed_element(&mut writer, e, false, options)?;
190                }
191            }
192            Event::Empty(ref e) if e.name().as_ref() == b"svg" => {
193                let mut elem = BytesStart::new("svg");
194                let mut empty_w = None;
195                let mut empty_h = None;
196                let mut empty_vb = None;
197                for attr in e.attributes().flatten() {
198                    let key = attr.key.as_ref();
199                    let val = std::str::from_utf8(&attr.value).unwrap_or("");
200                    if options.remove_metadata && key.starts_with(b"data-") {
201                        continue;
202                    }
203                    if key == b"width" {
204                        empty_w = Some(val.to_string());
205                    } else if key == b"height" {
206                        empty_h = Some(val.to_string());
207                    } else if key == b"viewBox" {
208                        empty_vb = Some(val.to_string());
209                    }
210
211                    if options.responsive && (key == b"width" || key == b"height") {
212                        continue;
213                    }
214                    if let (Some(prec), true) = (options.precision, is_numeric_attr(key)) {
215                        let rounded = round_numbers_in_str(val, prec);
216                        elem.push_attribute((key, rounded.as_bytes()));
217                        continue;
218                    }
219                    elem.push_attribute(attr);
220                }
221
222                if options.responsive && empty_vb.is_none() {
223                    let dims = empty_w.as_ref().zip(empty_h.as_ref());
224                    if let Some((w, h)) = dims {
225                        let w_px = parse_svg_dimension_to_px(w);
226                        let h_px = parse_svg_dimension_to_px(h);
227                        if let (Some(wp), Some(hp)) = (w_px, h_px) {
228                            let synthesized = format!("0 0 {wp} {hp}");
229                            let final_viewbox = if let Some(prec) = options.precision {
230                                round_numbers_in_str(&synthesized, prec)
231                            } else {
232                                Cow::Borrowed(synthesized.as_str())
233                            };
234                            elem.push_attribute(("viewBox", final_viewbox.as_ref()));
235                        }
236                    }
237                }
238
239                writer.write_event(Event::Empty(elem))?;
240            }
241            Event::Empty(ref e) => {
242                for g in group_stack.iter_mut() {
243                    if !g.written {
244                        writer.write_event(Event::Start(g.elem.clone()))?;
245                        g.written = true;
246                    }
247                }
248                write_transformed_element(&mut writer, e, true, options)?;
249            }
250            Event::Text(ref e) => {
251                let slice: &[u8] = e.as_ref();
252                let is_whitespace = slice.iter().all(|&b| b.is_ascii_whitespace());
253                if in_style_tag
254                    && let (Ok(text), Some(mono)) =
255                        (std::str::from_utf8(slice), options.monochrome.as_ref())
256                {
257                    for g in group_stack.iter_mut() {
258                        if !g.written {
259                            writer.write_event(Event::Start(g.elem.clone()))?;
260                            g.written = true;
261                        }
262                    }
263                    let transformed = transform_css_for_monochrome(text, mono);
264                    writer.write_event(Event::Text(quick_xml::events::BytesText::new(
265                        &transformed,
266                    )))?;
267                    continue;
268                }
269                if options.minify && is_whitespace {
270                    // Minify: omit whitespace text
271                    continue;
272                }
273                if !is_whitespace {
274                    for g in group_stack.iter_mut() {
275                        if !g.written {
276                            writer.write_event(Event::Start(g.elem.clone()))?;
277                            g.written = true;
278                        }
279                    }
280                }
281                writer.write_event(Event::Text(e.clone()))?;
282            }
283            Event::CData(ref e) => {
284                let slice: &[u8] = e.as_ref();
285                if in_style_tag
286                    && let (Ok(text), Some(mono)) =
287                        (std::str::from_utf8(slice), options.monochrome.as_ref())
288                {
289                    for g in group_stack.iter_mut() {
290                        if !g.written {
291                            writer.write_event(Event::Start(g.elem.clone()))?;
292                            g.written = true;
293                        }
294                    }
295                    let transformed = transform_css_for_monochrome(text, mono);
296                    writer.write_event(Event::CData(quick_xml::events::BytesCData::new(
297                        &transformed,
298                    )))?;
299                    continue;
300                }
301                for g in group_stack.iter_mut() {
302                    if !g.written {
303                        writer.write_event(Event::Start(g.elem.clone()))?;
304                        g.written = true;
305                    }
306                }
307                writer.write_event(Event::CData(e.clone()))?;
308            }
309            Event::End(ref e) => {
310                let tag_name = e.name();
311                if tag_name.as_ref() == b"style" {
312                    in_style_tag = false;
313                }
314                if options.strip_empty_groups
315                    && tag_name.as_ref() == b"g"
316                    && !group_stack.is_empty()
317                {
318                    let top = group_stack.pop().unwrap();
319                    if top.written {
320                        writer.write_event(Event::End(e.clone()))?;
321                    }
322                } else {
323                    for g in group_stack.iter_mut() {
324                        if !g.written {
325                            writer.write_event(Event::Start(g.elem.clone()))?;
326                            g.written = true;
327                        }
328                    }
329                    writer.write_event(Event::End(e.clone()))?;
330                }
331            }
332            other => {
333                writer.write_event(other)?;
334            }
335        }
336    }
337
338    Ok(out.into_inner())
339}
340
341fn is_numeric_attr(key: &[u8]) -> bool {
342    matches!(
343        key,
344        b"d" | b"points"
345            | b"x"
346            | b"y"
347            | b"width"
348            | b"height"
349            | b"cx"
350            | b"cy"
351            | b"r"
352            | b"rx"
353            | b"ry"
354            | b"x1"
355            | b"y1"
356            | b"x2"
357            | b"y2"
358            | b"stroke-width"
359            | b"viewBox"
360            | b"transform"
361    )
362}
363
364fn round_numbers_in_str<'a>(s: &'a str, precision: usize) -> Cow<'a, str> {
365    let bytes = s.as_bytes();
366    let n = bytes.len();
367    let mut i = 0;
368    let mut output: Option<String> = None;
369    let mut token_start = 0;
370
371    while i < n {
372        let ch = bytes[i];
373        let is_num_start = ch.is_ascii_digit()
374            || ((ch == b'-' || ch == b'+' || ch == b'.')
375                && i + 1 < n
376                && (bytes[i + 1].is_ascii_digit()
377                    || (ch != b'.'
378                        && bytes[i + 1] == b'.'
379                        && i + 2 < n
380                        && bytes[i + 2].is_ascii_digit())));
381
382        if is_num_start {
383            let start = i;
384            if bytes[i] == b'-' || bytes[i] == b'+' {
385                i += 1;
386            }
387            let mut has_dot = false;
388            let mut has_exp = false;
389            while i < n {
390                let c = bytes[i];
391                if c.is_ascii_digit() {
392                    i += 1;
393                } else if c == b'.' && !has_dot && !has_exp {
394                    has_dot = true;
395                    i += 1;
396                } else if (c == b'e' || c == b'E') && !has_exp {
397                    has_exp = true;
398                    i += 1;
399                    if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
400                        i += 1;
401                    }
402                } else {
403                    break;
404                }
405            }
406            let raw = &s[start..i];
407            let mut rounded = None;
408            if (has_dot || has_exp)
409                && let Ok(val) = raw.parse::<f64>()
410            {
411                use std::io::Write as IoWrite;
412                let mut fmt_buf = [0u8; 32];
413                let fmt_len = {
414                    let mut cursor = std::io::Cursor::new(&mut fmt_buf[..]);
415                    let _ = write!(cursor, "{val:.precision$}");
416                    cursor.position() as usize
417                };
418                let formatted_str = std::str::from_utf8(&fmt_buf[..fmt_len]).unwrap_or(raw);
419                let trimmed = if formatted_str.contains('.') {
420                    formatted_str.trim_end_matches('0').trim_end_matches('.')
421                } else {
422                    formatted_str
423                };
424                let normalized = if trimmed.is_empty() || trimmed == "-0" {
425                    "0"
426                } else {
427                    trimmed
428                };
429                if normalized != raw {
430                    rounded = Some(normalized.to_string());
431                }
432            }
433
434            if let Some(token_out) = rounded {
435                if output.is_none() {
436                    let mut out = String::with_capacity(s.len());
437                    out.push_str(&s[token_start..start]);
438                    output = Some(out);
439                }
440                output.as_mut().unwrap().push_str(&token_out);
441                token_start = i;
442            }
443        } else {
444            i += 1;
445        }
446    }
447
448    match output {
449        Some(mut out) => {
450            out.push_str(&s[token_start..]);
451            Cow::Owned(out)
452        }
453        None => Cow::Borrowed(s),
454    }
455}
456
457fn write_transformed_element<W: Write>(
458    writer: &mut Writer<W>,
459    e: &BytesStart,
460    is_empty: bool,
461    options: &TransformOptions,
462) -> Result<()> {
463    let name_bytes = e.name().into_inner();
464    let name = std::str::from_utf8(name_bytes).unwrap_or("g");
465    let is_path = name_bytes == b"path";
466
467    let mut is_path_empty_after_clean = false;
468    let mut cleaned_d = None;
469
470    if options.clean_paths && is_path {
471        for attr in e.attributes().flatten() {
472            if attr.key.as_ref() == b"d" {
473                let val = std::str::from_utf8(&attr.value).unwrap_or("");
474                let cleaned = clean_svg_path(val);
475                if cleaned.is_empty() {
476                    is_path_empty_after_clean = true;
477                } else {
478                    cleaned_d = Some(cleaned);
479                }
480                break;
481            }
482        }
483    }
484
485    if is_empty && is_path_empty_after_clean {
486        return Ok(());
487    }
488
489    let mut elem = BytesStart::new(name);
490
491    for attr in e.attributes().flatten() {
492        let key = attr.key.as_ref();
493        let val = std::str::from_utf8(&attr.value).unwrap_or("");
494
495        if options.remove_metadata && key.starts_with(b"data-") {
496            continue;
497        }
498
499        if key == b"d"
500            && is_path
501            && let Some(ref d_str) = cleaned_d
502        {
503            if let Some(prec) = options.precision {
504                let rounded = round_numbers_in_str(d_str, prec);
505                elem.push_attribute((key, rounded.as_bytes()));
506            } else {
507                elem.push_attribute((key, d_str.as_bytes()));
508            }
509            continue;
510        }
511
512        if let Some(ref mono_color) = options.monochrome {
513            if key == b"fill" {
514                if val == "none" {
515                    elem.push_attribute(("fill", "none"));
516                } else {
517                    elem.push_attribute(("fill", mono_color.as_str()));
518                }
519                continue;
520            } else if key == b"stroke" {
521                if val == "none" {
522                    elem.push_attribute(("stroke", "none"));
523                } else {
524                    elem.push_attribute(("stroke", mono_color.as_str()));
525                }
526                continue;
527            } else if key == b"stop-color" {
528                elem.push_attribute(("stop-color", mono_color.as_str()));
529                continue;
530            } else if key == b"color" {
531                elem.push_attribute(("color", mono_color.as_str()));
532                continue;
533            } else if key == b"style" {
534                let transformed_style = transform_style_for_monochrome(val, mono_color);
535                elem.push_attribute(("style", transformed_style.as_str()));
536                continue;
537            }
538        }
539
540        if let (Some(prec), true) = (options.precision, is_numeric_attr(key)) {
541            let rounded = round_numbers_in_str(val, prec);
542            elem.push_attribute((key, rounded.as_bytes()));
543            continue;
544        }
545
546        elem.push_attribute(attr);
547    }
548
549    if is_empty {
550        writer.write_event(Event::Empty(elem))?;
551    } else {
552        writer.write_event(Event::Start(elem))?;
553    }
554    Ok(())
555}
556
557fn transform_style_for_monochrome(style: &str, mono: &str) -> String {
558    let mut parts = Vec::new();
559    for decl in style.split(';') {
560        let decl = decl.trim();
561        if decl.is_empty() {
562            continue;
563        }
564        if let Some((prop, val)) = decl.split_once(':') {
565            let p = prop.trim();
566            let v = val.trim();
567            if (p == "fill" || p == "stroke" || p == "stop-color" || p == "color") && v != "none" {
568                parts.push(format!("{p}: {mono}"));
569            } else {
570                parts.push(decl.to_string());
571            }
572        } else {
573            parts.push(decl.to_string());
574        }
575    }
576    parts.join("; ")
577}
578
579fn parse_svg_dimension_to_px(s: &str) -> Option<f64> {
580    let value = s.trim();
581    let lower = value.to_ascii_lowercase();
582    let (number, unit) = ["px", "pt", "in", "cm", "mm", "pc"]
583        .into_iter()
584        .find_map(|unit| lower.strip_suffix(unit).map(|number| (number.trim(), unit)))
585        .unwrap_or((value, ""));
586    let number = number.parse::<f64>().ok()?;
587    let px = match unit {
588        "px" | "" => number,
589        "pt" => number * 96.0 / 72.0,
590        "in" => number * 96.0,
591        "cm" => number * 96.0 / 2.54,
592        "mm" => number * 96.0 / 25.4,
593        "pc" => number * 16.0,
594        _ => number,
595    };
596    Some(px)
597}
598
599pub(crate) fn transform_css_for_monochrome(css: &str, mono: &str) -> String {
600    let mut out = String::with_capacity(css.len());
601    let mut in_block = false;
602    let mut block_buf = String::new();
603
604    for ch in css.chars() {
605        if ch == '{' {
606            in_block = true;
607            out.push('{');
608            block_buf.clear();
609        } else if ch == '}' {
610            in_block = false;
611            let transformed = transform_style_for_monochrome(&block_buf, mono);
612            out.push_str(&transformed);
613            out.push('}');
614            block_buf.clear();
615        } else if in_block {
616            block_buf.push(ch);
617        } else {
618            out.push(ch);
619        }
620    }
621    if !block_buf.is_empty() {
622        out.push_str(&block_buf);
623    }
624    out
625}
626
627#[derive(Debug, PartialEq, Clone)]
628enum PathToken {
629    Command(char),
630    Number(f64),
631}
632
633fn tokenize_svg_path(d: &str) -> Vec<PathToken> {
634    let mut tokens = Vec::new();
635    let bytes = d.as_bytes();
636    let n = bytes.len();
637    let mut i = 0;
638
639    while i < n {
640        let ch = bytes[i];
641        if ch.is_ascii_whitespace() || ch == b',' {
642            i += 1;
643            continue;
644        }
645        if ch.is_ascii_alphabetic() {
646            tokens.push(PathToken::Command(ch as char));
647            i += 1;
648            continue;
649        }
650        let start = i;
651        if ch == b'+' || ch == b'-' {
652            i += 1;
653        }
654        let mut has_dot = false;
655        let mut has_exp = false;
656        while i < n {
657            let c = bytes[i];
658            if c.is_ascii_digit() {
659                i += 1;
660            } else if c == b'.' && !has_dot && !has_exp {
661                has_dot = true;
662                i += 1;
663            } else if (c == b'e' || c == b'E') && !has_exp {
664                has_exp = true;
665                i += 1;
666                if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
667                    i += 1;
668                }
669            } else {
670                break;
671            }
672        }
673        if start < i
674            && let Ok(num) = d[start..i].parse::<f64>()
675            && num.is_finite()
676        {
677            tokens.push(PathToken::Number(num));
678        }
679    }
680    tokens
681}
682
683pub fn clean_svg_path(d: &str) -> String {
684    let tokens = tokenize_svg_path(d);
685    if tokens.is_empty() {
686        return String::new();
687    }
688
689    let mut out = String::with_capacity(d.len());
690    let mut i = 0;
691    let n = tokens.len();
692
693    let mut curr_x = 0.0f64;
694    let mut curr_y = 0.0f64;
695    let mut start_x = 0.0f64;
696    let mut start_y = 0.0f64;
697    let mut last_cmd = ' ';
698    let mut current_cmd = ' ';
699    let mut has_drawn_segment = false;
700
701    while i < n {
702        match tokens[i] {
703            PathToken::Command(cmd) => {
704                current_cmd = cmd;
705                i += 1;
706                match cmd {
707                    'Z' | 'z' => {
708                        if last_cmd != 'Z' && last_cmd != 'z' {
709                            if !out.is_empty() {
710                                out.push(' ');
711                            }
712                            out.push('Z');
713                            last_cmd = 'Z';
714                            curr_x = start_x;
715                            curr_y = start_y;
716                            has_drawn_segment = true;
717                        }
718                    }
719                    _ => {}
720                }
721            }
722            PathToken::Number(num) => {
723                let cmd = current_cmd;
724                match cmd {
725                    'M' => {
726                        if i + 1 < n
727                            && let PathToken::Number(y_val) = tokens[i + 1]
728                        {
729                            curr_x = num;
730                            curr_y = y_val;
731                            start_x = curr_x;
732                            start_y = curr_y;
733                            if !out.is_empty() {
734                                out.push(' ');
735                            }
736                            out.push_str(&format!(
737                                "M {} {}",
738                                format_coord(num),
739                                format_coord(y_val)
740                            ));
741                            last_cmd = 'M';
742                            current_cmd = 'L';
743                            i += 2;
744                        } else {
745                            i += 1;
746                        }
747                    }
748                    'm' => {
749                        if i + 1 < n
750                            && let PathToken::Number(dy) = tokens[i + 1]
751                        {
752                            curr_x += num;
753                            curr_y += dy;
754                            start_x = curr_x;
755                            start_y = curr_y;
756                            if !out.is_empty() {
757                                out.push(' ');
758                            }
759                            out.push_str(&format!("m {} {}", format_coord(num), format_coord(dy)));
760                            last_cmd = 'm';
761                            current_cmd = 'l';
762                            i += 2;
763                        } else {
764                            i += 1;
765                        }
766                    }
767                    'L' => {
768                        if i + 1 < n
769                            && let PathToken::Number(y_val) = tokens[i + 1]
770                        {
771                            if (curr_x - num).abs() > 1e-6 || (curr_y - y_val).abs() > 1e-6 {
772                                curr_x = num;
773                                curr_y = y_val;
774                                if !out.is_empty() {
775                                    out.push(' ');
776                                }
777                                out.push_str(&format!(
778                                    "L {} {}",
779                                    format_coord(num),
780                                    format_coord(y_val)
781                                ));
782                                last_cmd = 'L';
783                                has_drawn_segment = true;
784                            }
785                            i += 2;
786                        } else {
787                            i += 1;
788                        }
789                    }
790                    'l' => {
791                        if i + 1 < n
792                            && let PathToken::Number(dy) = tokens[i + 1]
793                        {
794                            if num.abs() > 1e-6 || dy.abs() > 1e-6 {
795                                curr_x += num;
796                                curr_y += dy;
797                                if !out.is_empty() {
798                                    out.push(' ');
799                                }
800                                out.push_str(&format!(
801                                    "l {} {}",
802                                    format_coord(num),
803                                    format_coord(dy)
804                                ));
805                                last_cmd = 'l';
806                                has_drawn_segment = true;
807                            }
808                            i += 2;
809                        } else {
810                            i += 1;
811                        }
812                    }
813                    'H' => {
814                        if (curr_x - num).abs() > 1e-6 {
815                            curr_x = num;
816                            if !out.is_empty() {
817                                out.push(' ');
818                            }
819                            out.push_str(&format!("H {}", format_coord(num)));
820                            last_cmd = 'H';
821                            has_drawn_segment = true;
822                        }
823                        i += 1;
824                    }
825                    'h' => {
826                        if num.abs() > 1e-6 {
827                            curr_x += num;
828                            if !out.is_empty() {
829                                out.push(' ');
830                            }
831                            out.push_str(&format!("h {}", format_coord(num)));
832                            last_cmd = 'h';
833                            has_drawn_segment = true;
834                        }
835                        i += 1;
836                    }
837                    'V' => {
838                        if (curr_y - num).abs() > 1e-6 {
839                            curr_y = num;
840                            if !out.is_empty() {
841                                out.push(' ');
842                            }
843                            out.push_str(&format!("V {}", format_coord(num)));
844                            last_cmd = 'V';
845                            has_drawn_segment = true;
846                        }
847                        i += 1;
848                    }
849                    'v' => {
850                        if num.abs() > 1e-6 {
851                            curr_y += num;
852                            if !out.is_empty() {
853                                out.push(' ');
854                            }
855                            out.push_str(&format!("v {}", format_coord(num)));
856                            last_cmd = 'v';
857                            has_drawn_segment = true;
858                        }
859                        i += 1;
860                    }
861                    'C' => {
862                        if i + 5 < n
863                            && let (
864                                PathToken::Number(y1),
865                                PathToken::Number(x2),
866                                PathToken::Number(y2),
867                                PathToken::Number(x),
868                                PathToken::Number(y),
869                            ) = (
870                                &tokens[i + 1],
871                                &tokens[i + 2],
872                                &tokens[i + 3],
873                                &tokens[i + 4],
874                                &tokens[i + 5],
875                            )
876                        {
877                            curr_x = *x;
878                            curr_y = *y;
879                            if !out.is_empty() {
880                                out.push(' ');
881                            }
882                            out.push_str(&format!(
883                                "C {} {} {} {} {} {}",
884                                format_coord(num),
885                                format_coord(*y1),
886                                format_coord(*x2),
887                                format_coord(*y2),
888                                format_coord(*x),
889                                format_coord(*y)
890                            ));
891                            last_cmd = 'C';
892                            has_drawn_segment = true;
893                            i += 6;
894                        } else {
895                            i += 1;
896                        }
897                    }
898                    'c' => {
899                        if i + 5 < n
900                            && let (
901                                PathToken::Number(y1),
902                                PathToken::Number(x2),
903                                PathToken::Number(y2),
904                                PathToken::Number(dx),
905                                PathToken::Number(dy),
906                            ) = (
907                                &tokens[i + 1],
908                                &tokens[i + 2],
909                                &tokens[i + 3],
910                                &tokens[i + 4],
911                                &tokens[i + 5],
912                            )
913                        {
914                            curr_x += *dx;
915                            curr_y += *dy;
916                            if !out.is_empty() {
917                                out.push(' ');
918                            }
919                            out.push_str(&format!(
920                                "c {} {} {} {} {} {}",
921                                format_coord(num),
922                                format_coord(*y1),
923                                format_coord(*x2),
924                                format_coord(*y2),
925                                format_coord(*dx),
926                                format_coord(*dy)
927                            ));
928                            last_cmd = 'c';
929                            has_drawn_segment = true;
930                            i += 6;
931                        } else {
932                            i += 1;
933                        }
934                    }
935                    'S' => {
936                        if i + 3 < n
937                            && let (
938                                PathToken::Number(y2),
939                                PathToken::Number(x),
940                                PathToken::Number(y),
941                            ) = (&tokens[i + 1], &tokens[i + 2], &tokens[i + 3])
942                        {
943                            curr_x = *x;
944                            curr_y = *y;
945                            if !out.is_empty() {
946                                out.push(' ');
947                            }
948                            out.push_str(&format!(
949                                "S {} {} {} {}",
950                                format_coord(num),
951                                format_coord(*y2),
952                                format_coord(*x),
953                                format_coord(*y)
954                            ));
955                            last_cmd = 'S';
956                            has_drawn_segment = true;
957                            i += 4;
958                        } else {
959                            i += 1;
960                        }
961                    }
962                    's' => {
963                        if i + 3 < n
964                            && let (
965                                PathToken::Number(y2),
966                                PathToken::Number(dx),
967                                PathToken::Number(dy),
968                            ) = (&tokens[i + 1], &tokens[i + 2], &tokens[i + 3])
969                        {
970                            curr_x += *dx;
971                            curr_y += *dy;
972                            if !out.is_empty() {
973                                out.push(' ');
974                            }
975                            out.push_str(&format!(
976                                "s {} {} {} {}",
977                                format_coord(num),
978                                format_coord(*y2),
979                                format_coord(*dx),
980                                format_coord(*dy)
981                            ));
982                            last_cmd = 's';
983                            has_drawn_segment = true;
984                            i += 4;
985                        } else {
986                            i += 1;
987                        }
988                    }
989                    'Q' => {
990                        if i + 3 < n
991                            && let (
992                                PathToken::Number(y1),
993                                PathToken::Number(x),
994                                PathToken::Number(y),
995                            ) = (&tokens[i + 1], &tokens[i + 2], &tokens[i + 3])
996                        {
997                            curr_x = *x;
998                            curr_y = *y;
999                            if !out.is_empty() {
1000                                out.push(' ');
1001                            }
1002                            out.push_str(&format!(
1003                                "Q {} {} {} {}",
1004                                format_coord(num),
1005                                format_coord(*y1),
1006                                format_coord(*x),
1007                                format_coord(*y)
1008                            ));
1009                            last_cmd = 'Q';
1010                            has_drawn_segment = true;
1011                            i += 4;
1012                        } else {
1013                            i += 1;
1014                        }
1015                    }
1016                    'q' => {
1017                        if i + 3 < n
1018                            && let (
1019                                PathToken::Number(y1),
1020                                PathToken::Number(dx),
1021                                PathToken::Number(dy),
1022                            ) = (&tokens[i + 1], &tokens[i + 2], &tokens[i + 3])
1023                        {
1024                            curr_x += *dx;
1025                            curr_y += *dy;
1026                            if !out.is_empty() {
1027                                out.push(' ');
1028                            }
1029                            out.push_str(&format!(
1030                                "q {} {} {} {}",
1031                                format_coord(num),
1032                                format_coord(*y1),
1033                                format_coord(*dx),
1034                                format_coord(*dy)
1035                            ));
1036                            last_cmd = 'q';
1037                            has_drawn_segment = true;
1038                            i += 4;
1039                        } else {
1040                            i += 1;
1041                        }
1042                    }
1043                    'A' | 'a' => {
1044                        if i + 6 < n
1045                            && let (
1046                                PathToken::Number(ry),
1047                                PathToken::Number(x_rot),
1048                                PathToken::Number(large_arc),
1049                                PathToken::Number(sweep),
1050                                PathToken::Number(x),
1051                                PathToken::Number(y),
1052                            ) = (
1053                                &tokens[i + 1],
1054                                &tokens[i + 2],
1055                                &tokens[i + 3],
1056                                &tokens[i + 4],
1057                                &tokens[i + 5],
1058                                &tokens[i + 6],
1059                            )
1060                        {
1061                            if cmd == 'A' {
1062                                curr_x = *x;
1063                                curr_y = *y;
1064                            } else {
1065                                curr_x += *x;
1066                                curr_y += *y;
1067                            }
1068                            if !out.is_empty() {
1069                                out.push(' ');
1070                            }
1071                            out.push_str(&format!(
1072                                "{} {} {} {} {} {} {} {}",
1073                                cmd,
1074                                format_coord(num),
1075                                format_coord(*ry),
1076                                format_coord(*x_rot),
1077                                format_coord(*large_arc),
1078                                format_coord(*sweep),
1079                                format_coord(*x),
1080                                format_coord(*y)
1081                            ));
1082                            last_cmd = cmd;
1083                            has_drawn_segment = true;
1084                            i += 7;
1085                        } else {
1086                            i += 1;
1087                        }
1088                    }
1089                    _ => {
1090                        i += 1;
1091                    }
1092                }
1093            }
1094        }
1095    }
1096
1097    if !has_drawn_segment {
1098        return String::new();
1099    }
1100    out
1101}
1102
1103fn format_coord(v: f64) -> String {
1104    if v.fract() == 0.0 && v.abs() < 1e9 {
1105        format!("{:.0}", v)
1106    } else {
1107        let s = format!("{:.6}", v);
1108        let trimmed = s.trim_end_matches('0').trim_end_matches('.');
1109        if trimmed.is_empty() || trimmed == "-0" {
1110            "0".to_string()
1111        } else {
1112            trimmed.to_string()
1113        }
1114    }
1115}