Skip to main content

clipper2_rust/utils/
svg.rs

1// Copyright 2025 - Clipper2 Rust port
2// Direct port of clipper.svg.h / clipper.svg.cpp by Angus Johnson
3// Original Copyright: Angus Johnson 2010-2024
4// License: https://www.boost.org/LICENSE_1_0.txt
5//
6// Purpose: SVG writer and reader for path visualization
7
8use crate::core::{
9    scale_path, scale_paths, transform_paths, Path64, PathD, Paths64, PathsD, PointD, RectD,
10};
11use crate::FillRule;
12use std::fs;
13use std::io::Write;
14
15// ============================================================================
16// SVG constants
17// ============================================================================
18
19/// Default colors for SVG visualization (from clipper.svg.utils.h)
20pub const SUBJ_BRUSH_CLR: u32 = 0x1800009C;
21pub const SUBJ_STROKE_CLR: u32 = 0xFFB3B3DA;
22pub const CLIP_BRUSH_CLR: u32 = 0x129C0000;
23pub const CLIP_STROKE_CLR: u32 = 0xCCFFA07A;
24pub const SOLUTION_BRUSH_CLR: u32 = 0x4466FF66;
25
26// ============================================================================
27// Helper functions
28// ============================================================================
29
30/// Convert a u32 ARGB color to an HTML hex string (#RRGGBB).
31/// Direct port from C++ `ColorToHtml()`.
32pub fn color_to_html(clr: u32) -> String {
33    format!("#{:06x}", clr & 0xFFFFFF)
34}
35
36/// Extract the alpha channel as a fraction (0.0 to 1.0).
37/// Direct port from C++ `GetAlphaAsFrac()`.
38pub fn get_alpha_as_frac(clr: u32) -> f32 {
39    (clr >> 24) as f32 / 255.0
40}
41
42// ============================================================================
43// PathInfo - stores path data with rendering attributes
44// ============================================================================
45
46/// Stores a set of paths with their rendering attributes.
47/// Direct port from C++ `PathInfo` class.
48#[derive(Debug, Clone)]
49pub struct PathInfo {
50    pub paths: PathsD,
51    pub is_open_path: bool,
52    pub fillrule: FillRule,
53    pub brush_color: u32,
54    pub pen_color: u32,
55    pub pen_width: f64,
56    pub show_coords: bool,
57}
58
59impl PathInfo {
60    pub fn new(
61        paths: PathsD,
62        is_open: bool,
63        fillrule: FillRule,
64        brush_color: u32,
65        pen_color: u32,
66        pen_width: f64,
67        show_coords: bool,
68    ) -> Self {
69        Self {
70            paths,
71            is_open_path: is_open,
72            fillrule,
73            brush_color,
74            pen_color,
75            pen_width,
76            show_coords,
77        }
78    }
79}
80
81// ============================================================================
82// TextInfo - stores text label data
83// ============================================================================
84
85/// Stores a text label with its rendering attributes.
86/// Direct port from C++ `SvgWriter::TextInfo` class.
87#[derive(Debug, Clone)]
88pub struct TextInfo {
89    pub text: String,
90    pub font_name: String,
91    pub font_color: u32,
92    pub font_weight: u32,
93    pub font_size: u32,
94    pub x: f64,
95    pub y: f64,
96}
97
98impl TextInfo {
99    pub fn new(
100        text: &str,
101        font_name: &str,
102        font_color: u32,
103        font_weight: u32,
104        font_size: u32,
105        x: f64,
106        y: f64,
107    ) -> Self {
108        Self {
109            text: text.to_string(),
110            font_name: font_name.to_string(),
111            font_color,
112            font_weight,
113            font_size,
114            x,
115            y,
116        }
117    }
118}
119
120// ============================================================================
121// CoordsStyle - styling for coordinate display
122// ============================================================================
123
124#[derive(Debug, Clone)]
125struct CoordsStyle {
126    font_name: String,
127    font_color: u32,
128    font_size: u32,
129}
130
131impl Default for CoordsStyle {
132    fn default() -> Self {
133        Self {
134            font_name: "Verdana".to_string(),
135            font_color: 0xFF000000,
136            font_size: 11,
137        }
138    }
139}
140
141// ============================================================================
142// SvgWriter
143// ============================================================================
144
145/// SVG file writer for visualizing clipper paths.
146///
147/// Direct port from C++ `SvgWriter` class.
148/// Add paths and text, then save to an SVG file.
149///
150/// # Examples
151///
152/// ```
153/// use clipper2_rust::utils::svg::SvgWriter;
154/// use clipper2_rust::core::FillRule;
155///
156/// let mut svg = SvgWriter::new(0);
157/// // svg.add_paths_64(&paths, false, FillRule::NonZero, 0x1800009C, 0xFFB3B3DA, 0.8, false);
158/// // svg.save_to_file("output.svg", 800, 600, 20);
159/// ```
160pub struct SvgWriter {
161    scale: f64,
162    fill_rule: FillRule,
163    coords_style: CoordsStyle,
164    text_infos: Vec<TextInfo>,
165    path_infos: Vec<PathInfo>,
166}
167
168impl SvgWriter {
169    /// Create a new SvgWriter with the given precision (decimal places).
170    /// Direct port from C++ `SvgWriter(int precision)`.
171    pub fn new(precision: i32) -> Self {
172        Self {
173            scale: 10.0_f64.powi(precision),
174            fill_rule: FillRule::NonZero,
175            coords_style: CoordsStyle::default(),
176            text_infos: Vec::new(),
177            path_infos: Vec::new(),
178        }
179    }
180
181    /// Clear all stored paths and text.
182    pub fn clear(&mut self) {
183        self.path_infos.clear();
184        self.text_infos.clear();
185    }
186
187    /// Get the current fill rule.
188    pub fn fill_rule(&self) -> FillRule {
189        self.fill_rule
190    }
191
192    /// Set the coordinate display style.
193    /// Direct port from C++ `SetCoordsStyle()`.
194    pub fn set_coords_style(&mut self, font_name: &str, font_color: u32, font_size: u32) {
195        self.coords_style.font_name = font_name.to_string();
196        self.coords_style.font_color = font_color;
197        self.coords_style.font_size = font_size;
198    }
199
200    /// Add a text label at the given position.
201    /// Direct port from C++ `AddText()`.
202    pub fn add_text(&mut self, text: &str, font_color: u32, font_size: u32, x: f64, y: f64) {
203        self.text_infos
204            .push(TextInfo::new(text, "", font_color, 600, font_size, x, y));
205    }
206
207    /// Add a single Path64, scaling by the writer's precision.
208    /// Direct port from C++ `AddPath(const Path64&, ...)`.
209    #[allow(clippy::too_many_arguments)]
210    pub fn add_path_64(
211        &mut self,
212        path: &Path64,
213        is_open: bool,
214        fillrule: FillRule,
215        brush_color: u32,
216        pen_color: u32,
217        pen_width: f64,
218        show_coords: bool,
219    ) {
220        if path.is_empty() {
221            return;
222        }
223        let mut error_code = 0;
224        let scaled: PathD = scale_path::<f64, i64>(path, self.scale, self.scale, &mut error_code);
225        if error_code != 0 {
226            return;
227        }
228        self.path_infos.push(PathInfo::new(
229            vec![scaled],
230            is_open,
231            fillrule,
232            brush_color,
233            pen_color,
234            pen_width,
235            show_coords,
236        ));
237    }
238
239    /// Add a single PathD.
240    /// Direct port from C++ `AddPath(const PathD&, ...)`.
241    #[allow(clippy::too_many_arguments)]
242    pub fn add_path_d(
243        &mut self,
244        path: &PathD,
245        is_open: bool,
246        fillrule: FillRule,
247        brush_color: u32,
248        pen_color: u32,
249        pen_width: f64,
250        show_coords: bool,
251    ) {
252        if path.is_empty() {
253            return;
254        }
255        self.path_infos.push(PathInfo::new(
256            vec![path.clone()],
257            is_open,
258            fillrule,
259            brush_color,
260            pen_color,
261            pen_width,
262            show_coords,
263        ));
264    }
265
266    /// Add multiple Paths64, scaling by the writer's precision.
267    /// Direct port from C++ `AddPaths(const Paths64&, ...)`.
268    #[allow(clippy::too_many_arguments)]
269    pub fn add_paths_64(
270        &mut self,
271        paths: &Paths64,
272        is_open: bool,
273        fillrule: FillRule,
274        brush_color: u32,
275        pen_color: u32,
276        pen_width: f64,
277        show_coords: bool,
278    ) {
279        if paths.is_empty() {
280            return;
281        }
282        let mut error_code = 0;
283        let scaled: PathsD =
284            scale_paths::<f64, i64>(paths, self.scale, self.scale, &mut error_code);
285        if error_code != 0 {
286            return;
287        }
288        self.path_infos.push(PathInfo::new(
289            scaled,
290            is_open,
291            fillrule,
292            brush_color,
293            pen_color,
294            pen_width,
295            show_coords,
296        ));
297    }
298
299    /// Add multiple PathsD.
300    /// Direct port from C++ `AddPaths(const PathsD&, ...)`.
301    #[allow(clippy::too_many_arguments)]
302    pub fn add_paths_d(
303        &mut self,
304        paths: &PathsD,
305        is_open: bool,
306        fillrule: FillRule,
307        brush_color: u32,
308        pen_color: u32,
309        pen_width: f64,
310        show_coords: bool,
311    ) {
312        if paths.is_empty() {
313            return;
314        }
315        self.path_infos.push(PathInfo::new(
316            paths.clone(),
317            is_open,
318            fillrule,
319            brush_color,
320            pen_color,
321            pen_width,
322            show_coords,
323        ));
324    }
325
326    /// Save all stored paths and text to an SVG file.
327    /// Direct port from C++ `SaveToFile()`.
328    ///
329    /// Returns true on success, false on failure.
330    pub fn save_to_file(
331        &self,
332        filename: &str,
333        max_width: i32,
334        max_height: i32,
335        margin: i32,
336    ) -> bool {
337        // Compute bounding rect of all paths
338        let mut rec = RectD {
339            left: f64::MAX,
340            top: f64::MAX,
341            right: f64::MIN,
342            bottom: f64::MIN,
343        };
344        for pi in &self.path_infos {
345            for path in &pi.paths {
346                for pt in path {
347                    if pt.x < rec.left {
348                        rec.left = pt.x;
349                    }
350                    if pt.x > rec.right {
351                        rec.right = pt.x;
352                    }
353                    if pt.y < rec.top {
354                        rec.top = pt.y;
355                    }
356                    if pt.y > rec.bottom {
357                        rec.bottom = pt.y;
358                    }
359                }
360            }
361        }
362        if rec.left >= rec.right || rec.top >= rec.bottom {
363            return false;
364        }
365
366        let margin = margin.max(20);
367        let max_width = max_width.max(100);
368        let max_height = max_height.max(100);
369
370        let rec_width = rec.right - rec.left;
371        let rec_height = rec.bottom - rec.top;
372        let scale = ((max_width - margin * 2) as f64 / rec_width)
373            .min((max_height - margin * 2) as f64 / rec_height);
374
375        rec.left *= scale;
376        rec.top *= scale;
377        rec.right *= scale;
378        rec.bottom *= scale;
379        let offset_x = margin as f64 - rec.left;
380        let offset_y = margin as f64 - rec.top;
381
382        let file = fs::File::create(filename);
383        let mut file = match file {
384            Ok(f) => f,
385            Err(_) => return false,
386        };
387
388        // SVG header
389        let header = format!(
390            "<?xml version=\"1.0\" standalone=\"no\"?>\n\
391             <!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n\
392             \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n\n\
393             <svg width=\"{}px\" height=\"{}px\" viewBox=\"0 0 {} {}\" \
394             version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\">\n\n",
395            max_width, max_height, max_width, max_height
396        );
397        if write!(file, "{}", header).is_err() {
398            return false;
399        }
400
401        // First pass: render Positive/Negative fill rule paths with simulated fill
402        // (Skipped in Rust port as it requires calling Union which is complex and
403        // SVG only supports EvenOdd/NonZero natively. Paths with Positive/Negative
404        // fill rules will be rendered normally.)
405
406        // Main path rendering
407        for pi in &self.path_infos {
408            let brush_color = pi.brush_color;
409
410            let _ = write!(file, "  <path d=\"");
411            for path in &pi.paths {
412                if path.len() < 2 || (path.len() == 2 && !pi.is_open_path) {
413                    continue;
414                }
415                let _ = write!(
416                    file,
417                    " M {:.2} {:.2}",
418                    path[0].x * scale + offset_x,
419                    path[0].y * scale + offset_y
420                );
421                for pt in path {
422                    let _ = write!(
423                        file,
424                        " L {:.2} {:.2}",
425                        pt.x * scale + offset_x,
426                        pt.y * scale + offset_y
427                    );
428                }
429                if !pi.is_open_path {
430                    let _ = write!(file, " z");
431                }
432            }
433
434            let fill_rule_str = if pi.fillrule == FillRule::NonZero {
435                "nonzero"
436            } else {
437                "evenodd"
438            };
439
440            let _ = write!(
441                file,
442                "\"\n    style=\"fill:{}; fill-opacity:{:.2}; fill-rule:{}; \
443                 stroke:{}; stroke-opacity:{:.2}; stroke-width:{:.1};\"/>\n",
444                color_to_html(brush_color),
445                get_alpha_as_frac(brush_color),
446                fill_rule_str,
447                color_to_html(pi.pen_color),
448                get_alpha_as_frac(pi.pen_color),
449                pi.pen_width
450            );
451
452            // Coordinate display
453            if pi.show_coords {
454                let _ = writeln!(
455                    file,
456                    "  <g font-family=\"{}\" font-size=\"{}\" fill=\"{}\" fill-opacity=\"{:.2}\">",
457                    self.coords_style.font_name,
458                    self.coords_style.font_size,
459                    color_to_html(self.coords_style.font_color),
460                    get_alpha_as_frac(self.coords_style.font_color)
461                );
462                for path in &pi.paths {
463                    if path.len() < 2 || (path.len() == 2 && !pi.is_open_path) {
464                        continue;
465                    }
466                    for pt in path {
467                        let _ = writeln!(
468                            file,
469                            "    <text x=\"{}\" y=\"{}\">{:.0},{:.0}</text>",
470                            (pt.x * scale + offset_x) as i64,
471                            (pt.y * scale + offset_y) as i64,
472                            pt.x,
473                            pt.y
474                        );
475                    }
476                }
477                let _ = writeln!(file, "  </g>\n");
478            }
479        }
480
481        // Text labels
482        for ti in &self.text_infos {
483            let _ = writeln!(
484                file,
485                "  <g font-family=\"{}\" font-size=\"{}\" fill=\"{}\" fill-opacity=\"{:.2}\">",
486                if ti.font_name.is_empty() {
487                    "Verdana"
488                } else {
489                    &ti.font_name
490                },
491                ti.font_size,
492                color_to_html(ti.font_color),
493                get_alpha_as_frac(ti.font_color)
494            );
495            let _ = writeln!(
496                file,
497                "    <text x=\"{}\" y=\"{}\">{}</text>\n  </g>\n",
498                (ti.x * scale + offset_x) as i64,
499                (ti.y * scale + offset_y) as i64,
500                ti.text
501            );
502        }
503
504        let _ = writeln!(file, "</svg>");
505        true
506    }
507}
508
509// ============================================================================
510// SvgReader
511// ============================================================================
512
513/// SVG file reader that extracts path data from SVG files.
514///
515/// Direct port from C++ `SvgReader` class.
516/// Parses SVG `<path>` elements and extracts their coordinates.
517pub struct SvgReader {
518    pub xml: String,
519    path_infos: Vec<PathInfo>,
520}
521
522impl SvgReader {
523    pub fn new() -> Self {
524        Self {
525            xml: String::new(),
526            path_infos: Vec::new(),
527        }
528    }
529
530    pub fn clear(&mut self) {
531        self.path_infos.clear();
532    }
533
534    /// Load and parse an SVG file. Returns true if paths were found.
535    /// Direct port from C++ `LoadFromFile()`.
536    pub fn load_from_file(&mut self, filename: &str) -> bool {
537        self.clear();
538        let content = match fs::read_to_string(filename) {
539            Ok(s) => s,
540            Err(_) => return false,
541        };
542        self.xml = content;
543        self.parse_paths();
544        !self.path_infos.is_empty()
545    }
546
547    /// Parse all `<path>` elements from the loaded XML.
548    fn parse_paths(&mut self) {
549        let xml = self.xml.clone();
550        let mut pos = 0;
551        while let Some(path_start) = xml[pos..].find("<path") {
552            let abs_start = pos + path_start + 5;
553            if let Some(path_end) = xml[abs_start..].find("/>") {
554                let abs_end = abs_start + path_end;
555                let element = &xml[abs_start..abs_end];
556                self.load_path(element);
557                pos = abs_end + 2;
558            } else {
559                break;
560            }
561        }
562    }
563
564    /// Parse a single path element's `d` attribute.
565    /// Direct port from C++ `LoadPath()`.
566    fn load_path(&mut self, element: &str) -> bool {
567        let d_attr = match element.find("d=\"") {
568            Some(pos) => &element[pos + 3..],
569            None => return false,
570        };
571
572        let d_end = match d_attr.find('"') {
573            Some(pos) => pos,
574            None => return false,
575        };
576        let d_value = &d_attr[..d_end];
577
578        let mut paths: PathsD = Vec::new();
579        let mut current_path: PathD = Vec::new();
580        let mut x: f64;
581        let mut y: f64;
582        let mut command;
583        let mut is_relative;
584
585        let chars: Vec<char> = d_value.chars().collect();
586        let mut i = 0;
587
588        // Skip leading whitespace
589        while i < chars.len() && chars[i].is_whitespace() {
590            i += 1;
591        }
592
593        // Expect 'M' or 'm' as first command
594        if i >= chars.len() {
595            return false;
596        }
597        if chars[i] == 'M' {
598            is_relative = false;
599            i += 1;
600        } else if chars[i] == 'm' {
601            is_relative = true;
602            i += 1;
603        } else {
604            return false;
605        }
606        command = 'M';
607
608        // Read initial x,y
609        if let Some((val, next)) = parse_number(&chars, i) {
610            x = val;
611            i = next;
612        } else {
613            return false;
614        }
615        if let Some((val, next)) = parse_number(&chars, i) {
616            y = val;
617            i = next;
618        } else {
619            return false;
620        }
621        current_path.push(PointD::new(x, y));
622
623        // Process remaining path data
624        while i < chars.len() {
625            // Skip whitespace
626            while i < chars.len() && chars[i].is_whitespace() {
627                i += 1;
628            }
629            if i >= chars.len() {
630                break;
631            }
632
633            // Check for command letter
634            if chars[i].is_ascii_alphabetic() {
635                let ch = chars[i];
636                match ch.to_ascii_uppercase() {
637                    'L' | 'M' => {
638                        command = ch.to_ascii_uppercase();
639                        is_relative = ch.is_ascii_lowercase();
640                        i += 1;
641                    }
642                    'H' => {
643                        command = 'H';
644                        is_relative = ch.is_ascii_lowercase();
645                        i += 1;
646                    }
647                    'V' => {
648                        command = 'V';
649                        is_relative = ch.is_ascii_lowercase();
650                        i += 1;
651                    }
652                    'Z' => {
653                        if current_path.len() > 2 {
654                            paths.push(current_path.clone());
655                        }
656                        current_path.clear();
657                        i += 1;
658                        continue;
659                    }
660                    _ => break, // Unsupported command
661                }
662            }
663
664            // Parse values based on current command
665            match command {
666                'H' => {
667                    if let Some((val, next)) = parse_number(&chars, i) {
668                        x = if is_relative { x + val } else { val };
669                        current_path.push(PointD::new(x, y));
670                        i = next;
671                    } else {
672                        break;
673                    }
674                }
675                'V' => {
676                    if let Some((val, next)) = parse_number(&chars, i) {
677                        y = if is_relative { y + val } else { val };
678                        current_path.push(PointD::new(x, y));
679                        i = next;
680                    } else {
681                        break;
682                    }
683                }
684                'L' | 'M' => {
685                    if let Some((vx, next1)) = parse_number(&chars, i) {
686                        if let Some((vy, next2)) = parse_number(&chars, next1) {
687                            x = if is_relative { x + vx } else { vx };
688                            y = if is_relative { y + vy } else { vy };
689                            current_path.push(PointD::new(x, y));
690                            i = next2;
691                        } else {
692                            break;
693                        }
694                    } else {
695                        break;
696                    }
697                }
698                _ => break,
699            }
700        }
701
702        // Push final path if it has enough points
703        if current_path.len() > 3 {
704            paths.push(current_path);
705        }
706
707        if paths.is_empty() {
708            return false;
709        }
710
711        self.path_infos.push(PathInfo::new(
712            paths,
713            false,
714            FillRule::EvenOdd,
715            0,
716            0xFF000000,
717            1.0,
718            false,
719        ));
720        true
721    }
722
723    /// Extract all paths from the loaded SVG.
724    /// Direct port from C++ `GetPaths()`.
725    pub fn get_paths(&self) -> PathsD {
726        let mut result = PathsD::new();
727        for pi in &self.path_infos {
728            for path in &pi.paths {
729                result.push(path.clone());
730            }
731        }
732        result
733    }
734}
735
736impl Default for SvgReader {
737    fn default() -> Self {
738        Self::new()
739    }
740}
741
742// ============================================================================
743// SVG utility functions (from clipper.svg.utils.h)
744// ============================================================================
745
746/// Add a caption text to the SVG.
747pub fn svg_add_caption(svg: &mut SvgWriter, caption: &str, x: i32, y: i32) {
748    svg.add_text(caption, 0xFF000000, 14, x as f64, y as f64);
749}
750
751/// Add subject paths (Paths64) to the SVG.
752pub fn svg_add_subject_64(svg: &mut SvgWriter, paths: &Paths64, fillrule: FillRule) {
753    let tmp: PathsD = transform_paths(paths);
754    svg.add_paths_d(
755        &tmp,
756        false,
757        fillrule,
758        SUBJ_BRUSH_CLR,
759        SUBJ_STROKE_CLR,
760        0.8,
761        false,
762    );
763}
764
765/// Add subject paths (PathsD) to the SVG.
766pub fn svg_add_subject_d(svg: &mut SvgWriter, paths: &PathsD, fillrule: FillRule) {
767    svg.add_paths_d(
768        paths,
769        false,
770        fillrule,
771        SUBJ_BRUSH_CLR,
772        SUBJ_STROKE_CLR,
773        0.8,
774        false,
775    );
776}
777
778/// Add open subject paths (Paths64) to the SVG.
779pub fn svg_add_open_subject_64(svg: &mut SvgWriter, paths: &Paths64, fillrule: FillRule) {
780    let tmp: PathsD = transform_paths(paths);
781    svg.add_paths_d(&tmp, true, fillrule, 0x0, 0xCCB3B3DA, 1.3, false);
782}
783
784/// Add open subject paths (PathsD) to the SVG.
785pub fn svg_add_open_subject_d(
786    svg: &mut SvgWriter,
787    paths: &PathsD,
788    fillrule: FillRule,
789    is_joined: bool,
790) {
791    if is_joined {
792        svg.add_paths_d(
793            paths,
794            false,
795            fillrule,
796            SUBJ_BRUSH_CLR,
797            SUBJ_STROKE_CLR,
798            1.3,
799            false,
800        );
801    } else {
802        svg.add_paths_d(paths, true, fillrule, 0x0, SUBJ_STROKE_CLR, 1.3, false);
803    }
804}
805
806/// Add clip paths (Paths64) to the SVG.
807pub fn svg_add_clip_64(svg: &mut SvgWriter, paths: &Paths64, fillrule: FillRule) {
808    let tmp: PathsD = transform_paths(paths);
809    svg.add_paths_d(
810        &tmp,
811        false,
812        fillrule,
813        CLIP_BRUSH_CLR,
814        CLIP_STROKE_CLR,
815        0.8,
816        false,
817    );
818}
819
820/// Add clip paths (PathsD) to the SVG.
821pub fn svg_add_clip_d(svg: &mut SvgWriter, paths: &PathsD, fillrule: FillRule) {
822    svg.add_paths_d(
823        paths,
824        false,
825        fillrule,
826        CLIP_BRUSH_CLR,
827        CLIP_STROKE_CLR,
828        0.8,
829        false,
830    );
831}
832
833/// Add solution paths (Paths64) to the SVG.
834pub fn svg_add_solution_64(
835    svg: &mut SvgWriter,
836    paths: &Paths64,
837    fillrule: FillRule,
838    show_coords: bool,
839) {
840    let tmp: PathsD = transform_paths(paths);
841    svg.add_paths_d(
842        &tmp,
843        false,
844        fillrule,
845        SOLUTION_BRUSH_CLR,
846        0xFF003300,
847        1.0,
848        show_coords,
849    );
850}
851
852/// Add solution paths (PathsD) to the SVG.
853pub fn svg_add_solution_d(
854    svg: &mut SvgWriter,
855    paths: &PathsD,
856    fillrule: FillRule,
857    show_coords: bool,
858) {
859    svg.add_paths_d(
860        paths,
861        false,
862        fillrule,
863        SOLUTION_BRUSH_CLR,
864        0xFF003300,
865        1.2,
866        show_coords,
867    );
868}
869
870/// Add open solution paths (Paths64) to the SVG.
871pub fn svg_add_open_solution_64(
872    svg: &mut SvgWriter,
873    paths: &Paths64,
874    fillrule: FillRule,
875    show_coords: bool,
876    is_joined: bool,
877) {
878    let tmp: PathsD = transform_paths(paths);
879    svg.add_paths_d(
880        &tmp,
881        !is_joined,
882        fillrule,
883        0x0,
884        0xFF006600,
885        1.8,
886        show_coords,
887    );
888}
889
890/// Add open solution paths (PathsD) to the SVG.
891pub fn svg_add_open_solution_d(
892    svg: &mut SvgWriter,
893    paths: &PathsD,
894    fillrule: FillRule,
895    show_coords: bool,
896    is_joined: bool,
897) {
898    svg.add_paths_d(
899        paths,
900        !is_joined,
901        fillrule,
902        0x0,
903        0xFF006600,
904        1.8,
905        show_coords,
906    );
907}
908
909/// Save SVG to file with sensible defaults and coordinate styling.
910pub fn svg_save_to_file(
911    svg: &mut SvgWriter,
912    filename: &str,
913    max_width: i32,
914    max_height: i32,
915    margin: i32,
916) {
917    svg.set_coords_style("Verdana", 0xFF0000AA, 7);
918    svg.save_to_file(filename, max_width, max_height, margin);
919}
920
921// ============================================================================
922// Internal helpers
923// ============================================================================
924
925/// Parse a number from a character slice starting at position `start`.
926/// Returns the parsed value and the new position after the number.
927fn parse_number(chars: &[char], start: usize) -> Option<(f64, usize)> {
928    let mut i = start;
929    // Skip whitespace and commas
930    while i < chars.len() && (chars[i].is_whitespace() || chars[i] == ',') {
931        i += 1;
932    }
933    if i >= chars.len() {
934        return None;
935    }
936
937    let start_pos = i;
938    let is_neg = chars[i] == '-';
939    if is_neg {
940        i += 1;
941    }
942    if chars.get(i) == Some(&'+') {
943        i += 1;
944    }
945
946    let mut has_digits = false;
947    let mut has_dot = false;
948
949    while i < chars.len() {
950        if chars[i] == '.' {
951            if has_dot {
952                break;
953            }
954            has_dot = true;
955        } else if chars[i].is_ascii_digit() {
956            has_digits = true;
957        } else {
958            break;
959        }
960        i += 1;
961    }
962
963    if !has_digits {
964        return None;
965    }
966
967    let num_str: String = chars[start_pos..i].iter().collect();
968    match num_str.parse::<f64>() {
969        Ok(val) => Some((val, i)),
970        Err(_) => None,
971    }
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977    use crate::core::Point64;
978
979    #[test]
980    fn test_color_to_html() {
981        assert_eq!(color_to_html(0xFF123456), "#123456");
982        assert_eq!(color_to_html(0x00000000), "#000000");
983        assert_eq!(color_to_html(0xFFFFFFFF), "#ffffff");
984    }
985
986    #[test]
987    fn test_get_alpha_as_frac() {
988        assert!((get_alpha_as_frac(0xFF000000) - 1.0).abs() < 0.01);
989        assert!((get_alpha_as_frac(0x80000000) - 0.502).abs() < 0.01);
990        assert!((get_alpha_as_frac(0x00000000) - 0.0).abs() < 0.01);
991    }
992
993    #[test]
994    fn test_svg_writer_new() {
995        let svg = SvgWriter::new(0);
996        assert_eq!(svg.fill_rule(), FillRule::NonZero);
997    }
998
999    #[test]
1000    fn test_svg_writer_add_text() {
1001        let mut svg = SvgWriter::new(0);
1002        svg.add_text("Hello", 0xFF000000, 12, 10.0, 20.0);
1003        assert_eq!(svg.text_infos.len(), 1);
1004        assert_eq!(svg.text_infos[0].text, "Hello");
1005    }
1006
1007    #[test]
1008    fn test_svg_writer_add_paths() {
1009        let mut svg = SvgWriter::new(0);
1010        let paths = vec![vec![
1011            Point64::new(0, 0),
1012            Point64::new(100, 0),
1013            Point64::new(100, 100),
1014            Point64::new(0, 100),
1015        ]];
1016        svg.add_paths_64(
1017            &paths,
1018            false,
1019            FillRule::NonZero,
1020            0xFF0000FF,
1021            0xFF000000,
1022            1.0,
1023            false,
1024        );
1025        assert_eq!(svg.path_infos.len(), 1);
1026    }
1027
1028    #[test]
1029    fn test_svg_writer_add_empty_paths() {
1030        let mut svg = SvgWriter::new(0);
1031        let paths: Paths64 = vec![];
1032        svg.add_paths_64(&paths, false, FillRule::NonZero, 0, 0, 1.0, false);
1033        assert_eq!(svg.path_infos.len(), 0);
1034    }
1035
1036    #[test]
1037    fn test_svg_writer_clear() {
1038        let mut svg = SvgWriter::new(0);
1039        svg.add_text("Test", 0xFF000000, 12, 0.0, 0.0);
1040        let paths = vec![vec![
1041            PointD::new(0.0, 0.0),
1042            PointD::new(1.0, 1.0),
1043            PointD::new(2.0, 0.0),
1044        ]];
1045        svg.add_paths_d(&paths, false, FillRule::NonZero, 0, 0, 1.0, false);
1046        svg.clear();
1047        assert!(svg.path_infos.is_empty());
1048        assert!(svg.text_infos.is_empty());
1049    }
1050
1051    #[test]
1052    fn test_svg_writer_save_to_file() {
1053        let mut svg = SvgWriter::new(0);
1054        let paths = vec![vec![
1055            PointD::new(0.0, 0.0),
1056            PointD::new(100.0, 0.0),
1057            PointD::new(100.0, 100.0),
1058            PointD::new(0.0, 100.0),
1059        ]];
1060        svg.add_paths_d(
1061            &paths,
1062            false,
1063            FillRule::NonZero,
1064            0x800000FF,
1065            0xFF000000,
1066            1.0,
1067            false,
1068        );
1069        svg.add_text("Test SVG", 0xFF000000, 14, 10.0, 10.0);
1070
1071        let tmp_file = std::env::temp_dir().join("clipper2_test_output.svg");
1072        let result = svg.save_to_file(tmp_file.to_str().unwrap(), 800, 600, 20);
1073        assert!(result);
1074
1075        let content = fs::read_to_string(&tmp_file).unwrap();
1076        assert!(content.contains("<svg"));
1077        assert!(content.contains("</svg>"));
1078        assert!(content.contains("<path"));
1079        assert!(content.contains("Test SVG"));
1080
1081        let _ = fs::remove_file(&tmp_file);
1082    }
1083
1084    #[test]
1085    fn test_svg_reader_new() {
1086        let reader = SvgReader::new();
1087        assert!(reader.xml.is_empty());
1088        assert!(reader.get_paths().is_empty());
1089    }
1090
1091    #[test]
1092    fn test_svg_reader_roundtrip() {
1093        // Write an SVG and read it back
1094        let mut writer = SvgWriter::new(0);
1095        let paths = vec![vec![
1096            PointD::new(10.0, 10.0),
1097            PointD::new(90.0, 10.0),
1098            PointD::new(90.0, 90.0),
1099            PointD::new(10.0, 90.0),
1100        ]];
1101        writer.add_paths_d(
1102            &paths,
1103            false,
1104            FillRule::NonZero,
1105            0x800000FF,
1106            0xFF000000,
1107            1.0,
1108            false,
1109        );
1110
1111        let tmp_file = std::env::temp_dir().join("clipper2_test_roundtrip.svg");
1112        let filename = tmp_file.to_str().unwrap();
1113        assert!(writer.save_to_file(filename, 400, 400, 20));
1114
1115        let mut reader = SvgReader::new();
1116        assert!(reader.load_from_file(filename));
1117        let read_paths = reader.get_paths();
1118        assert!(!read_paths.is_empty());
1119
1120        let _ = fs::remove_file(&tmp_file);
1121    }
1122
1123    #[test]
1124    fn test_parse_number() {
1125        let chars: Vec<char> = "123.45, -67.8".chars().collect();
1126        let (val, next) = parse_number(&chars, 0).unwrap();
1127        assert!((val - 123.45).abs() < 0.001);
1128        let (val2, _) = parse_number(&chars, next).unwrap();
1129        assert!((val2 - (-67.8)).abs() < 0.001);
1130    }
1131
1132    #[test]
1133    fn test_parse_number_whitespace() {
1134        let chars: Vec<char> = "  42  ".chars().collect();
1135        let (val, _) = parse_number(&chars, 0).unwrap();
1136        assert!((val - 42.0).abs() < 0.001);
1137    }
1138
1139    #[test]
1140    fn test_parse_number_empty() {
1141        let chars: Vec<char> = "   ".chars().collect();
1142        assert!(parse_number(&chars, 0).is_none());
1143    }
1144}