1use 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 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 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}