Skip to main content

jugar_probar/media/
svg_exporter.rs

1//! SVG Screenshot Generation (Feature 3)
2//!
3//! Vector-based screenshots for resolution-independent documentation.
4//!
5//! ## EXTREME TDD: Tests written FIRST per spec
6//!
7//! ## Toyota Way Application:
8//! - **Poka-Yoke**: Type-safe SVG generation prevents malformed output
9//! - **Muda**: Efficient string building minimizes allocations
10
11use crate::driver::Screenshot;
12use crate::media::png_exporter::{Annotation, AnnotationKind};
13use crate::result::{ProbarError, ProbarResult};
14use std::fmt::Write as FmtWrite;
15use std::fs;
16use std::io::Write;
17use std::path::Path;
18
19/// SVG compression options
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum SvgCompression {
22    /// No compression, human-readable output
23    #[default]
24    None,
25    /// Minified output (no whitespace)
26    Minified,
27}
28
29/// Configuration for SVG export
30#[derive(Debug, Clone)]
31pub struct SvgConfig {
32    /// Viewbox dimensions (width, height)
33    pub viewbox: (u32, u32),
34    /// Preserve aspect ratio
35    pub preserve_aspect_ratio: bool,
36    /// Embed fonts as base64
37    pub embed_fonts: bool,
38    /// Output compression level
39    pub compression: SvgCompression,
40    /// Include XML declaration
41    pub include_xml_declaration: bool,
42    /// Title for accessibility
43    pub title: Option<String>,
44    /// Description for accessibility
45    pub description: Option<String>,
46}
47
48impl Default for SvgConfig {
49    fn default() -> Self {
50        Self {
51            viewbox: (800, 600),
52            preserve_aspect_ratio: true,
53            embed_fonts: false,
54            compression: SvgCompression::None,
55            include_xml_declaration: true,
56            title: None,
57            description: None,
58        }
59    }
60}
61
62impl SvgConfig {
63    /// Create a new SVG config with viewbox dimensions
64    #[must_use]
65    pub const fn new(width: u32, height: u32) -> Self {
66        Self {
67            viewbox: (width, height),
68            preserve_aspect_ratio: true,
69            embed_fonts: false,
70            compression: SvgCompression::None,
71            include_xml_declaration: true,
72            title: None,
73            description: None,
74        }
75    }
76
77    /// Set viewbox dimensions
78    #[must_use]
79    pub const fn with_viewbox(mut self, width: u32, height: u32) -> Self {
80        self.viewbox = (width, height);
81        self
82    }
83
84    /// Set preserve aspect ratio
85    #[must_use]
86    pub const fn with_preserve_aspect_ratio(mut self, preserve: bool) -> Self {
87        self.preserve_aspect_ratio = preserve;
88        self
89    }
90
91    /// Set compression level
92    #[must_use]
93    pub const fn with_compression(mut self, compression: SvgCompression) -> Self {
94        self.compression = compression;
95        self
96    }
97
98    /// Set XML declaration inclusion
99    #[must_use]
100    pub const fn with_xml_declaration(mut self, include: bool) -> Self {
101        self.include_xml_declaration = include;
102        self
103    }
104
105    /// Set title
106    #[must_use]
107    pub fn with_title(mut self, title: impl Into<String>) -> Self {
108        self.title = Some(title.into());
109        self
110    }
111
112    /// Set description
113    #[must_use]
114    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
115        self.description = Some(desc.into());
116        self
117    }
118}
119
120/// SVG exporter for creating vector screenshots
121#[derive(Debug, Clone)]
122pub struct SvgExporter {
123    config: SvgConfig,
124}
125
126impl Default for SvgExporter {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl SvgExporter {
133    /// Create a new SVG exporter with default config
134    #[must_use]
135    pub fn new() -> Self {
136        Self {
137            config: SvgConfig::default(),
138        }
139    }
140
141    /// Create a new SVG exporter with custom config
142    #[must_use]
143    pub const fn with_config(config: SvgConfig) -> Self {
144        Self { config }
145    }
146
147    /// Get the current config
148    #[must_use]
149    pub const fn config(&self) -> &SvgConfig {
150        &self.config
151    }
152
153    /// Export a screenshot as embedded image SVG
154    ///
155    /// This embeds the raster image as a base64-encoded data URI within the SVG.
156    /// The SVG wrapper provides scalability and annotation support.
157    ///
158    /// # Errors
159    ///
160    /// Returns error if screenshot data is invalid
161    pub fn from_screenshot(&self, screenshot: &Screenshot) -> ProbarResult<String> {
162        self.from_screenshot_with_annotations(screenshot, &[])
163    }
164
165    /// Export a screenshot with annotations as SVG
166    ///
167    /// # Errors
168    ///
169    /// Returns error if screenshot data is invalid
170    pub fn from_screenshot_with_annotations(
171        &self,
172        screenshot: &Screenshot,
173        annotations: &[Annotation],
174    ) -> ProbarResult<String> {
175        let mut svg = String::with_capacity(screenshot.data.len() * 2);
176        let newline = self.newline();
177        let indent = self.indent();
178
179        // XML declaration
180        if self.config.include_xml_declaration {
181            svg.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
182            svg.push_str(newline);
183        }
184
185        // SVG root element
186        let (width, height) = self.config.viewbox;
187        let preserve_aspect = if self.config.preserve_aspect_ratio {
188            "xMidYMid meet"
189        } else {
190            "none"
191        };
192
193        write!(
194            svg,
195            "<svg xmlns=\"http://www.w3.org/2000/svg\" \
196             xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
197             width=\"{width}\" height=\"{height}\" \
198             viewBox=\"0 0 {width} {height}\" \
199             preserveAspectRatio=\"{preserve_aspect}\">"
200        )
201        .map_err(|e| ProbarError::ImageProcessing {
202            message: e.to_string(),
203        })?;
204        svg.push_str(newline);
205
206        // Title for accessibility
207        if let Some(ref title) = self.config.title {
208            write!(svg, "{indent}<title>{}</title>", escape_xml(title)).map_err(|e| {
209                ProbarError::ImageProcessing {
210                    message: e.to_string(),
211                }
212            })?;
213            svg.push_str(newline);
214        }
215
216        // Description for accessibility
217        if let Some(ref desc) = self.config.description {
218            write!(svg, "{indent}<desc>{}</desc>", escape_xml(desc)).map_err(|e| {
219                ProbarError::ImageProcessing {
220                    message: e.to_string(),
221                }
222            })?;
223            svg.push_str(newline);
224        }
225
226        // Embedded image
227        let base64_data = base64_encode(&screenshot.data);
228        write!(
229            svg,
230            "{indent}<image x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" \
231             xlink:href=\"data:image/png;base64,{base64_data}\"/>"
232        )
233        .map_err(|e| ProbarError::ImageProcessing {
234            message: e.to_string(),
235        })?;
236        svg.push_str(newline);
237
238        // Annotations group
239        if !annotations.is_empty() {
240            write!(svg, "{indent}<g id=\"annotations\">").map_err(|e| {
241                ProbarError::ImageProcessing {
242                    message: e.to_string(),
243                }
244            })?;
245            svg.push_str(newline);
246
247            for annotation in annotations {
248                self.render_annotation(&mut svg, annotation, &format!("{indent}{indent}"))?;
249            }
250
251            write!(svg, "{indent}</g>").map_err(|e| ProbarError::ImageProcessing {
252                message: e.to_string(),
253            })?;
254            svg.push_str(newline);
255        }
256
257        // Close SVG
258        svg.push_str("</svg>");
259        svg.push_str(newline);
260
261        Ok(svg)
262    }
263
264    /// Create an SVG from raw shapes (no raster image)
265    ///
266    /// # Errors
267    ///
268    /// Returns error if rendering fails
269    pub fn from_shapes(&self, shapes: &[SvgShape]) -> ProbarResult<String> {
270        let mut svg = String::with_capacity(4096);
271        let newline = self.newline();
272        let indent = self.indent();
273
274        // XML declaration
275        if self.config.include_xml_declaration {
276            svg.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
277            svg.push_str(newline);
278        }
279
280        // SVG root element
281        let (width, height) = self.config.viewbox;
282        let preserve_aspect = if self.config.preserve_aspect_ratio {
283            "xMidYMid meet"
284        } else {
285            "none"
286        };
287
288        write!(
289            svg,
290            "<svg xmlns=\"http://www.w3.org/2000/svg\" \
291             width=\"{width}\" height=\"{height}\" \
292             viewBox=\"0 0 {width} {height}\" \
293             preserveAspectRatio=\"{preserve_aspect}\">"
294        )
295        .map_err(|e| ProbarError::ImageProcessing {
296            message: e.to_string(),
297        })?;
298        svg.push_str(newline);
299
300        // Title
301        if let Some(ref title) = self.config.title {
302            write!(svg, "{indent}<title>{}</title>", escape_xml(title)).map_err(|e| {
303                ProbarError::ImageProcessing {
304                    message: e.to_string(),
305                }
306            })?;
307            svg.push_str(newline);
308        }
309
310        // Description
311        if let Some(ref desc) = self.config.description {
312            write!(svg, "{indent}<desc>{}</desc>", escape_xml(desc)).map_err(|e| {
313                ProbarError::ImageProcessing {
314                    message: e.to_string(),
315                }
316            })?;
317            svg.push_str(newline);
318        }
319
320        // Render shapes
321        for shape in shapes {
322            self.render_shape(&mut svg, shape, indent)?;
323            svg.push_str(newline);
324        }
325
326        // Close SVG
327        svg.push_str("</svg>");
328        svg.push_str(newline);
329
330        Ok(svg)
331    }
332
333    /// Save SVG to file
334    ///
335    /// # Errors
336    ///
337    /// Returns error if file cannot be written
338    pub fn save(&self, svg_content: &str, path: &Path) -> ProbarResult<()> {
339        let mut file = fs::File::create(path)?;
340        file.write_all(svg_content.as_bytes())?;
341        Ok(())
342    }
343
344    /// Get newline based on compression setting
345    fn newline(&self) -> &'static str {
346        match self.config.compression {
347            SvgCompression::None => "\n",
348            SvgCompression::Minified => "",
349        }
350    }
351
352    /// Get indent based on compression setting
353    fn indent(&self) -> &'static str {
354        match self.config.compression {
355            SvgCompression::None => "  ",
356            SvgCompression::Minified => "",
357        }
358    }
359
360    /// Render an annotation to SVG
361    fn render_annotation(
362        &self,
363        svg: &mut String,
364        annotation: &Annotation,
365        indent: &str,
366    ) -> ProbarResult<()> {
367        let newline = self.newline();
368        let color = color_to_svg(&annotation.color);
369
370        match annotation.kind {
371            AnnotationKind::Rectangle => {
372                write!(
373                    svg,
374                    "{indent}<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
375                     fill=\"none\" stroke=\"{color}\" stroke-width=\"2\"/>",
376                    annotation.x, annotation.y, annotation.width, annotation.height
377                )
378                .map_err(|e| ProbarError::ImageProcessing {
379                    message: e.to_string(),
380                })?;
381            }
382            AnnotationKind::FilledRectangle => {
383                write!(
384                    svg,
385                    "{indent}<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{color}\"/>",
386                    annotation.x, annotation.y, annotation.width, annotation.height
387                )
388                .map_err(|e| ProbarError::ImageProcessing {
389                    message: e.to_string(),
390                })?;
391            }
392            AnnotationKind::Circle => {
393                // Use width as diameter for circle
394                let r = annotation.width / 2;
395                let cx = annotation.x + r;
396                let cy = annotation.y + r;
397                write!(
398                    svg,
399                    "{indent}<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r}\" \
400                     fill=\"none\" stroke=\"{color}\" stroke-width=\"2\"/>"
401                )
402                .map_err(|e| ProbarError::ImageProcessing {
403                    message: e.to_string(),
404                })?;
405            }
406            AnnotationKind::Arrow => {
407                // Arrow from (x, y) to (x + width, y + height)
408                let x1 = annotation.x;
409                let y1 = annotation.y;
410                let x2 = annotation.x + annotation.width;
411                let y2 = annotation.y + annotation.height;
412                write!(
413                    svg,
414                    "{indent}<defs>{newline}\
415                     {indent}  <marker id=\"arrowhead-{}\" markerWidth=\"10\" markerHeight=\"7\" \
416                     refX=\"9\" refY=\"3.5\" orient=\"auto\">{newline}\
417                     {indent}    <polygon points=\"0 0, 10 3.5, 0 7\" fill=\"{color}\"/>{newline}\
418                     {indent}  </marker>{newline}\
419                     {indent}</defs>{newline}\
420                     {indent}<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" \
421                     stroke=\"{color}\" stroke-width=\"2\" marker-end=\"url(#arrowhead-{})\"/>",
422                    annotation.x, annotation.x
423                )
424                .map_err(|e| ProbarError::ImageProcessing {
425                    message: e.to_string(),
426                })?;
427            }
428            AnnotationKind::Highlight => {
429                // Semi-transparent highlight
430                write!(
431                    svg,
432                    "{indent}<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
433                     fill=\"{color}\" fill-opacity=\"0.3\"/>",
434                    annotation.x, annotation.y, annotation.width, annotation.height
435                )
436                .map_err(|e| ProbarError::ImageProcessing {
437                    message: e.to_string(),
438                })?;
439            }
440        }
441
442        // Add label if present
443        if let Some(ref label) = annotation.label {
444            svg.push_str(newline);
445            write!(
446                svg,
447                "{indent}<text x=\"{}\" y=\"{}\" fill=\"{color}\" font-size=\"12\">{}</text>",
448                annotation.x,
449                annotation.y.saturating_sub(5),
450                escape_xml(label)
451            )
452            .map_err(|e| ProbarError::ImageProcessing {
453                message: e.to_string(),
454            })?;
455        }
456
457        svg.push_str(newline);
458        Ok(())
459    }
460
461    /// Render a shape to SVG
462    fn render_shape(&self, svg: &mut String, shape: &SvgShape, indent: &str) -> ProbarResult<()> {
463        // Helper macro to convert fmt::Error to ProbarError
464        macro_rules! w {
465            ($($arg:tt)*) => {
466                write!($($arg)*).map_err(|e| ProbarError::ImageProcessing { message: e.to_string() })
467            };
468        }
469
470        match shape {
471            SvgShape::Rect {
472                x,
473                y,
474                width,
475                height,
476                fill,
477                stroke,
478                stroke_width,
479                rx,
480                ry,
481            } => {
482                w!(
483                    svg,
484                    "{indent}<rect x=\"{x}\" y=\"{y}\" width=\"{width}\" height=\"{height}\""
485                )?;
486                if let Some(fill) = fill {
487                    w!(svg, " fill=\"{fill}\"")?;
488                }
489                if let Some(stroke) = stroke {
490                    w!(svg, " stroke=\"{stroke}\"")?;
491                }
492                if let Some(sw) = stroke_width {
493                    w!(svg, " stroke-width=\"{sw}\"")?;
494                }
495                if let Some(rx) = rx {
496                    w!(svg, " rx=\"{rx}\"")?;
497                }
498                if let Some(ry) = ry {
499                    w!(svg, " ry=\"{ry}\"")?;
500                }
501                w!(svg, "/>")?;
502            }
503            SvgShape::Circle {
504                cx,
505                cy,
506                r,
507                fill,
508                stroke,
509                stroke_width,
510            } => {
511                w!(svg, "{indent}<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r}\"")?;
512                if let Some(fill) = fill {
513                    w!(svg, " fill=\"{fill}\"")?;
514                }
515                if let Some(stroke) = stroke {
516                    w!(svg, " stroke=\"{stroke}\"")?;
517                }
518                if let Some(sw) = stroke_width {
519                    w!(svg, " stroke-width=\"{sw}\"")?;
520                }
521                w!(svg, "/>")?;
522            }
523            SvgShape::Ellipse {
524                cx,
525                cy,
526                rx,
527                ry,
528                fill,
529                stroke,
530                stroke_width,
531            } => {
532                w!(
533                    svg,
534                    "{indent}<ellipse cx=\"{cx}\" cy=\"{cy}\" rx=\"{rx}\" ry=\"{ry}\""
535                )?;
536                if let Some(fill) = fill {
537                    w!(svg, " fill=\"{fill}\"")?;
538                }
539                if let Some(stroke) = stroke {
540                    w!(svg, " stroke=\"{stroke}\"")?;
541                }
542                if let Some(sw) = stroke_width {
543                    w!(svg, " stroke-width=\"{sw}\"")?;
544                }
545                w!(svg, "/>")?;
546            }
547            SvgShape::Line {
548                x1,
549                y1,
550                x2,
551                y2,
552                stroke,
553                stroke_width,
554            } => {
555                w!(
556                    svg,
557                    "{indent}<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\""
558                )?;
559                if let Some(stroke) = stroke {
560                    w!(svg, " stroke=\"{stroke}\"")?;
561                }
562                if let Some(sw) = stroke_width {
563                    w!(svg, " stroke-width=\"{sw}\"")?;
564                }
565                w!(svg, "/>")?;
566            }
567            SvgShape::Polyline {
568                points,
569                stroke,
570                stroke_width,
571                fill,
572            } => {
573                let points_str: String = points
574                    .iter()
575                    .map(|(x, y)| format!("{x},{y}"))
576                    .collect::<Vec<_>>()
577                    .join(" ");
578                w!(svg, "{indent}<polyline points=\"{points_str}\"")?;
579                if let Some(fill) = fill {
580                    w!(svg, " fill=\"{fill}\"")?;
581                } else {
582                    w!(svg, " fill=\"none\"")?;
583                }
584                if let Some(stroke) = stroke {
585                    w!(svg, " stroke=\"{stroke}\"")?;
586                }
587                if let Some(sw) = stroke_width {
588                    w!(svg, " stroke-width=\"{sw}\"")?;
589                }
590                w!(svg, "/>")?;
591            }
592            SvgShape::Polygon {
593                points,
594                fill,
595                stroke,
596                stroke_width,
597            } => {
598                let points_str: String = points
599                    .iter()
600                    .map(|(x, y)| format!("{x},{y}"))
601                    .collect::<Vec<_>>()
602                    .join(" ");
603                w!(svg, "{indent}<polygon points=\"{points_str}\"")?;
604                if let Some(fill) = fill {
605                    w!(svg, " fill=\"{fill}\"")?;
606                }
607                if let Some(stroke) = stroke {
608                    w!(svg, " stroke=\"{stroke}\"")?;
609                }
610                if let Some(sw) = stroke_width {
611                    w!(svg, " stroke-width=\"{sw}\"")?;
612                }
613                w!(svg, "/>")?;
614            }
615            SvgShape::Path {
616                d,
617                fill,
618                stroke,
619                stroke_width,
620            } => {
621                w!(svg, "{indent}<path d=\"{d}\"")?;
622                if let Some(fill) = fill {
623                    w!(svg, " fill=\"{fill}\"")?;
624                }
625                if let Some(stroke) = stroke {
626                    w!(svg, " stroke=\"{stroke}\"")?;
627                }
628                if let Some(sw) = stroke_width {
629                    w!(svg, " stroke-width=\"{sw}\"")?;
630                }
631                w!(svg, "/>")?;
632            }
633            SvgShape::Text {
634                x,
635                y,
636                content,
637                font_size,
638                fill,
639                font_family,
640            } => {
641                w!(svg, "{indent}<text x=\"{x}\" y=\"{y}\"")?;
642                if let Some(size) = font_size {
643                    w!(svg, " font-size=\"{size}\"")?;
644                }
645                if let Some(fill) = fill {
646                    w!(svg, " fill=\"{fill}\"")?;
647                }
648                if let Some(family) = font_family {
649                    w!(svg, " font-family=\"{family}\"")?;
650                }
651                w!(svg, ">{}</text>", escape_xml(content))?;
652            }
653            SvgShape::Group { id, children } => {
654                if let Some(id) = id {
655                    w!(svg, "{indent}<g id=\"{id}\">")?;
656                } else {
657                    w!(svg, "{indent}<g>")?;
658                }
659                let newline = self.newline();
660                svg.push_str(newline);
661                let child_indent = format!("{indent}  ");
662                for child in children {
663                    self.render_shape(svg, child, &child_indent)?;
664                    svg.push_str(newline);
665                }
666                w!(svg, "{indent}</g>")?;
667            }
668        }
669        Ok(())
670    }
671}
672
673/// SVG shape primitives
674#[derive(Debug, Clone)]
675#[allow(missing_docs)]
676pub enum SvgShape {
677    /// Rectangle
678    Rect {
679        x: f64,
680        y: f64,
681        width: f64,
682        height: f64,
683        fill: Option<String>,
684        stroke: Option<String>,
685        stroke_width: Option<f64>,
686        rx: Option<f64>,
687        ry: Option<f64>,
688    },
689    /// Circle
690    Circle {
691        cx: f64,
692        cy: f64,
693        r: f64,
694        fill: Option<String>,
695        stroke: Option<String>,
696        stroke_width: Option<f64>,
697    },
698    /// Ellipse
699    Ellipse {
700        cx: f64,
701        cy: f64,
702        rx: f64,
703        ry: f64,
704        fill: Option<String>,
705        stroke: Option<String>,
706        stroke_width: Option<f64>,
707    },
708    /// Line
709    Line {
710        x1: f64,
711        y1: f64,
712        x2: f64,
713        y2: f64,
714        stroke: Option<String>,
715        stroke_width: Option<f64>,
716    },
717    /// Polyline (open path)
718    Polyline {
719        points: Vec<(f64, f64)>,
720        stroke: Option<String>,
721        stroke_width: Option<f64>,
722        fill: Option<String>,
723    },
724    /// Polygon (closed path)
725    Polygon {
726        points: Vec<(f64, f64)>,
727        fill: Option<String>,
728        stroke: Option<String>,
729        stroke_width: Option<f64>,
730    },
731    /// Path with SVG path data
732    Path {
733        d: String,
734        fill: Option<String>,
735        stroke: Option<String>,
736        stroke_width: Option<f64>,
737    },
738    /// Text
739    Text {
740        x: f64,
741        y: f64,
742        content: String,
743        font_size: Option<f64>,
744        fill: Option<String>,
745        font_family: Option<String>,
746    },
747    /// Group of shapes
748    Group {
749        id: Option<String>,
750        children: Vec<SvgShape>,
751    },
752}
753
754impl SvgShape {
755    /// Create a rectangle
756    #[must_use]
757    pub fn rect(x: f64, y: f64, width: f64, height: f64) -> Self {
758        Self::Rect {
759            x,
760            y,
761            width,
762            height,
763            fill: None,
764            stroke: None,
765            stroke_width: None,
766            rx: None,
767            ry: None,
768        }
769    }
770
771    /// Create a circle
772    #[must_use]
773    pub fn circle(cx: f64, cy: f64, r: f64) -> Self {
774        Self::Circle {
775            cx,
776            cy,
777            r,
778            fill: None,
779            stroke: None,
780            stroke_width: None,
781        }
782    }
783
784    /// Create a line
785    #[must_use]
786    pub fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> Self {
787        Self::Line {
788            x1,
789            y1,
790            x2,
791            y2,
792            stroke: None,
793            stroke_width: None,
794        }
795    }
796
797    /// Create text
798    #[must_use]
799    pub fn text(x: f64, y: f64, content: impl Into<String>) -> Self {
800        Self::Text {
801            x,
802            y,
803            content: content.into(),
804            font_size: None,
805            fill: None,
806            font_family: None,
807        }
808    }
809
810    /// Set fill color
811    #[must_use]
812    pub fn with_fill(mut self, fill: impl Into<String>) -> Self {
813        match &mut self {
814            Self::Rect { fill: f, .. }
815            | Self::Circle { fill: f, .. }
816            | Self::Ellipse { fill: f, .. }
817            | Self::Polygon { fill: f, .. }
818            | Self::Path { fill: f, .. }
819            | Self::Polyline { fill: f, .. } => *f = Some(fill.into()),
820            Self::Text { fill: f, .. } => *f = Some(fill.into()),
821            Self::Line { .. } | Self::Group { .. } => {}
822        }
823        self
824    }
825
826    /// Set stroke color
827    #[must_use]
828    pub fn with_stroke(mut self, stroke: impl Into<String>) -> Self {
829        match &mut self {
830            Self::Rect { stroke: s, .. }
831            | Self::Circle { stroke: s, .. }
832            | Self::Ellipse { stroke: s, .. }
833            | Self::Line { stroke: s, .. }
834            | Self::Polyline { stroke: s, .. }
835            | Self::Polygon { stroke: s, .. }
836            | Self::Path { stroke: s, .. } => *s = Some(stroke.into()),
837            Self::Text { .. } | Self::Group { .. } => {}
838        }
839        self
840    }
841
842    /// Set stroke width
843    #[must_use]
844    pub fn with_stroke_width(mut self, width: f64) -> Self {
845        match &mut self {
846            Self::Rect {
847                stroke_width: sw, ..
848            }
849            | Self::Circle {
850                stroke_width: sw, ..
851            }
852            | Self::Ellipse {
853                stroke_width: sw, ..
854            }
855            | Self::Line {
856                stroke_width: sw, ..
857            }
858            | Self::Polyline {
859                stroke_width: sw, ..
860            }
861            | Self::Polygon {
862                stroke_width: sw, ..
863            }
864            | Self::Path {
865                stroke_width: sw, ..
866            } => *sw = Some(width),
867            Self::Text { .. } | Self::Group { .. } => {}
868        }
869        self
870    }
871}
872
873/// Convert annotation color to SVG color string
874fn color_to_svg(color: &[u8; 4]) -> String {
875    format!(
876        "rgba({},{},{},{})",
877        color[0],
878        color[1],
879        color[2],
880        f64::from(color[3]) / 255.0
881    )
882}
883
884/// Escape XML special characters
885fn escape_xml(s: &str) -> String {
886    s.replace('&', "&amp;")
887        .replace('<', "&lt;")
888        .replace('>', "&gt;")
889        .replace('"', "&quot;")
890        .replace('\'', "&apos;")
891}
892
893/// Base64 encode data
894fn base64_encode(data: &[u8]) -> String {
895    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
896
897    let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
898
899    for chunk in data.chunks(3) {
900        // Safety: chunks(3) on non-empty slice produces chunks of 1, 2, or 3 elements
901        // The 0 case handles the (impossible) edge case to satisfy exhaustiveness
902        let n = match chunk.len() {
903            3 => (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]),
904            2 => (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8),
905            1 => u32::from(chunk[0]) << 16,
906            0 => continue, // Empty chunk - skip (cannot happen with chunks(3) on non-empty data)
907            _ => (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]),
908        };
909
910        result.push(char::from(ALPHABET[(n >> 18) as usize & 0x3F]));
911        result.push(char::from(ALPHABET[(n >> 12) as usize & 0x3F]));
912
913        if chunk.len() > 1 {
914            result.push(char::from(ALPHABET[(n >> 6) as usize & 0x3F]));
915        } else {
916            result.push('=');
917        }
918
919        if chunk.len() > 2 {
920            result.push(char::from(ALPHABET[n as usize & 0x3F]));
921        } else {
922            result.push('=');
923        }
924    }
925
926    result
927}
928
929#[cfg(test)]
930#[allow(clippy::unwrap_used, clippy::expect_used)]
931mod tests {
932    use super::*;
933    use std::time::SystemTime;
934
935    fn test_screenshot() -> Screenshot {
936        Screenshot {
937            data: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
938            width: 100,
939            height: 100,
940            device_pixel_ratio: 1.0,
941            timestamp: SystemTime::now(),
942        }
943    }
944
945    mod svg_config_tests {
946        use super::*;
947
948        #[test]
949        fn test_default_config() {
950            let config = SvgConfig::default();
951            assert_eq!(config.viewbox, (800, 600));
952            assert!(config.preserve_aspect_ratio);
953            assert!(!config.embed_fonts);
954            assert_eq!(config.compression, SvgCompression::None);
955            assert!(config.include_xml_declaration);
956        }
957
958        #[test]
959        fn test_new_with_dimensions() {
960            let config = SvgConfig::new(1920, 1080);
961            assert_eq!(config.viewbox, (1920, 1080));
962        }
963
964        #[test]
965        fn test_builder_chain() {
966            let config = SvgConfig::new(800, 600)
967                .with_viewbox(1024, 768)
968                .with_preserve_aspect_ratio(false)
969                .with_compression(SvgCompression::Minified)
970                .with_xml_declaration(false)
971                .with_title("Test Screenshot")
972                .with_description("A test description");
973
974            assert_eq!(config.viewbox, (1024, 768));
975            assert!(!config.preserve_aspect_ratio);
976            assert_eq!(config.compression, SvgCompression::Minified);
977            assert!(!config.include_xml_declaration);
978            assert_eq!(config.title, Some("Test Screenshot".to_string()));
979            assert_eq!(config.description, Some("A test description".to_string()));
980        }
981    }
982
983    mod svg_exporter_tests {
984        use super::*;
985
986        #[test]
987        fn test_default_exporter() {
988            let exporter = SvgExporter::new();
989            assert_eq!(exporter.config().viewbox, (800, 600));
990        }
991
992        #[test]
993        fn test_exporter_with_config() {
994            let config = SvgConfig::new(1920, 1080);
995            let exporter = SvgExporter::with_config(config);
996            assert_eq!(exporter.config().viewbox, (1920, 1080));
997        }
998
999        #[test]
1000        fn test_from_screenshot() {
1001            let screenshot = test_screenshot();
1002            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1003
1004            let svg = exporter.from_screenshot(&screenshot).unwrap();
1005
1006            assert!(svg.contains("<?xml version=\"1.0\""));
1007            assert!(svg.contains("<svg"));
1008            assert!(svg.contains("xmlns=\"http://www.w3.org/2000/svg\""));
1009            assert!(svg.contains("<image"));
1010            assert!(svg.contains("data:image/png;base64,"));
1011            assert!(svg.contains("</svg>"));
1012        }
1013
1014        #[test]
1015        fn test_from_screenshot_with_annotations() {
1016            let screenshot = test_screenshot();
1017            let annotations = vec![Annotation::rectangle(10, 10, 50, 30)];
1018            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1019
1020            let svg = exporter
1021                .from_screenshot_with_annotations(&screenshot, &annotations)
1022                .unwrap();
1023
1024            assert!(svg.contains("<g id=\"annotations\">"));
1025            assert!(svg.contains("<rect"));
1026        }
1027
1028        #[test]
1029        fn test_minified_output() {
1030            let screenshot = test_screenshot();
1031            let config = SvgConfig::new(100, 100)
1032                .with_compression(SvgCompression::Minified)
1033                .with_xml_declaration(false);
1034            let exporter = SvgExporter::with_config(config);
1035
1036            let svg = exporter.from_screenshot(&screenshot).unwrap();
1037
1038            // Minified should have no newlines within
1039            assert!(!svg.contains("\n  "));
1040        }
1041
1042        #[test]
1043        fn test_with_title_and_description() {
1044            let screenshot = test_screenshot();
1045            let config = SvgConfig::new(100, 100)
1046                .with_title("My Screenshot")
1047                .with_description("Test description");
1048            let exporter = SvgExporter::with_config(config);
1049
1050            let svg = exporter.from_screenshot(&screenshot).unwrap();
1051
1052            assert!(svg.contains("<title>My Screenshot</title>"));
1053            assert!(svg.contains("<desc>Test description</desc>"));
1054        }
1055    }
1056
1057    mod svg_shape_tests {
1058        use super::*;
1059
1060        #[test]
1061        fn test_from_shapes() {
1062            let shapes = vec![
1063                SvgShape::rect(10.0, 10.0, 100.0, 50.0)
1064                    .with_fill("blue")
1065                    .with_stroke("black")
1066                    .with_stroke_width(2.0),
1067                SvgShape::circle(150.0, 50.0, 25.0).with_fill("red"),
1068                SvgShape::line(200.0, 10.0, 300.0, 60.0)
1069                    .with_stroke("green")
1070                    .with_stroke_width(3.0),
1071                SvgShape::text(10.0, 100.0, "Hello SVG").with_fill("black"),
1072            ];
1073
1074            let exporter = SvgExporter::with_config(SvgConfig::new(400, 150));
1075            let svg = exporter.from_shapes(&shapes).unwrap();
1076
1077            assert!(svg.contains("<rect"));
1078            assert!(svg.contains("fill=\"blue\""));
1079            assert!(svg.contains("<circle"));
1080            assert!(svg.contains("<line"));
1081            assert!(svg.contains("<text"));
1082            assert!(svg.contains("Hello SVG"));
1083        }
1084
1085        #[test]
1086        fn test_shape_builders() {
1087            let rect = SvgShape::rect(0.0, 0.0, 100.0, 100.0);
1088            assert!(matches!(rect, SvgShape::Rect { x: _, y: _, .. }));
1089
1090            let circle = SvgShape::circle(50.0, 50.0, 25.0);
1091            assert!(matches!(
1092                circle,
1093                SvgShape::Circle {
1094                    cx: _,
1095                    cy: _,
1096                    r: _,
1097                    ..
1098                }
1099            ));
1100
1101            let line = SvgShape::line(0.0, 0.0, 100.0, 100.0);
1102            assert!(matches!(
1103                line,
1104                SvgShape::Line {
1105                    x1: _,
1106                    y1: _,
1107                    x2: _,
1108                    y2: _,
1109                    ..
1110                }
1111            ));
1112
1113            let text = SvgShape::text(10.0, 20.0, "Test");
1114            assert!(matches!(text, SvgShape::Text { x: _, y: _, .. }));
1115        }
1116
1117        #[test]
1118        fn test_group_shapes() {
1119            let shapes = vec![SvgShape::Group {
1120                id: Some("my-group".to_string()),
1121                children: vec![
1122                    SvgShape::rect(0.0, 0.0, 50.0, 50.0).with_fill("blue"),
1123                    SvgShape::circle(25.0, 25.0, 10.0).with_fill("red"),
1124                ],
1125            }];
1126
1127            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1128            let svg = exporter.from_shapes(&shapes).unwrap();
1129
1130            assert!(svg.contains("<g id=\"my-group\">"));
1131            assert!(svg.contains("<rect"));
1132            assert!(svg.contains("<circle"));
1133            assert!(svg.contains("</g>"));
1134        }
1135
1136        #[test]
1137        fn test_group_without_id() {
1138            let shapes = vec![SvgShape::Group {
1139                id: None,
1140                children: vec![SvgShape::rect(0.0, 0.0, 50.0, 50.0).with_fill("blue")],
1141            }];
1142
1143            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1144            let svg = exporter.from_shapes(&shapes).unwrap();
1145
1146            assert!(svg.contains("<g>"));
1147            assert!(!svg.contains("<g id="));
1148        }
1149
1150        #[test]
1151        fn test_ellipse_shape() {
1152            let shapes = vec![SvgShape::Ellipse {
1153                cx: 100.0,
1154                cy: 50.0,
1155                rx: 80.0,
1156                ry: 40.0,
1157                fill: Some("yellow".to_string()),
1158                stroke: Some("black".to_string()),
1159                stroke_width: Some(2.0),
1160            }];
1161
1162            let exporter = SvgExporter::with_config(SvgConfig::new(200, 100));
1163            let svg = exporter.from_shapes(&shapes).unwrap();
1164
1165            assert!(svg.contains("<ellipse"));
1166            assert!(svg.contains("cx=\"100\""));
1167            assert!(svg.contains("rx=\"80\""));
1168            assert!(svg.contains("ry=\"40\""));
1169            assert!(svg.contains("fill=\"yellow\""));
1170        }
1171
1172        #[test]
1173        fn test_polyline_shape() {
1174            let shapes = vec![SvgShape::Polyline {
1175                points: vec![(10.0, 10.0), (50.0, 90.0), (90.0, 10.0)],
1176                stroke: Some("purple".to_string()),
1177                stroke_width: Some(3.0),
1178                fill: None,
1179            }];
1180
1181            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1182            let svg = exporter.from_shapes(&shapes).unwrap();
1183
1184            assert!(svg.contains("<polyline"));
1185            assert!(svg.contains("points=\"10,10 50,90 90,10\""));
1186            assert!(svg.contains("stroke=\"purple\""));
1187            assert!(svg.contains("fill=\"none\""));
1188        }
1189
1190        #[test]
1191        fn test_polyline_with_fill() {
1192            let shapes = vec![SvgShape::Polyline {
1193                points: vec![(10.0, 10.0), (50.0, 90.0), (90.0, 10.0)],
1194                stroke: None,
1195                stroke_width: None,
1196                fill: Some("orange".to_string()),
1197            }];
1198
1199            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1200            let svg = exporter.from_shapes(&shapes).unwrap();
1201
1202            assert!(svg.contains("fill=\"orange\""));
1203        }
1204
1205        #[test]
1206        fn test_polygon_shape() {
1207            let shapes = vec![SvgShape::Polygon {
1208                points: vec![(50.0, 10.0), (90.0, 90.0), (10.0, 90.0)],
1209                fill: Some("green".to_string()),
1210                stroke: Some("darkgreen".to_string()),
1211                stroke_width: Some(2.0),
1212            }];
1213
1214            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1215            let svg = exporter.from_shapes(&shapes).unwrap();
1216
1217            assert!(svg.contains("<polygon"));
1218            assert!(svg.contains("points=\"50,10 90,90 10,90\""));
1219            assert!(svg.contains("fill=\"green\""));
1220        }
1221
1222        #[test]
1223        fn test_path_shape() {
1224            let shapes = vec![SvgShape::Path {
1225                d: "M10 10 L90 90 L10 90 Z".to_string(),
1226                fill: Some("cyan".to_string()),
1227                stroke: Some("teal".to_string()),
1228                stroke_width: Some(1.0),
1229            }];
1230
1231            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1232            let svg = exporter.from_shapes(&shapes).unwrap();
1233
1234            assert!(svg.contains("<path"));
1235            assert!(svg.contains("d=\"M10 10 L90 90 L10 90 Z\""));
1236            assert!(svg.contains("fill=\"cyan\""));
1237        }
1238
1239        #[test]
1240        fn test_rect_with_rounded_corners() {
1241            let shapes = vec![SvgShape::Rect {
1242                x: 10.0,
1243                y: 10.0,
1244                width: 80.0,
1245                height: 60.0,
1246                fill: Some("blue".to_string()),
1247                stroke: None,
1248                stroke_width: None,
1249                rx: Some(10.0),
1250                ry: Some(5.0),
1251            }];
1252
1253            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1254            let svg = exporter.from_shapes(&shapes).unwrap();
1255
1256            assert!(svg.contains("rx=\"10\""));
1257            assert!(svg.contains("ry=\"5\""));
1258        }
1259
1260        #[test]
1261        fn test_text_with_font_options() {
1262            let shapes = vec![SvgShape::Text {
1263                x: 10.0,
1264                y: 50.0,
1265                content: "Hello".to_string(),
1266                font_size: Some(24.0),
1267                fill: Some("black".to_string()),
1268                font_family: Some("Arial".to_string()),
1269            }];
1270
1271            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1272            let svg = exporter.from_shapes(&shapes).unwrap();
1273
1274            assert!(svg.contains("<text"));
1275            assert!(svg.contains("font-size=\"24\""));
1276            assert!(svg.contains("font-family=\"Arial\""));
1277            assert!(svg.contains(">Hello</text>"));
1278        }
1279
1280        #[test]
1281        fn test_shapes_preserve_aspect_ratio_false() {
1282            let config = SvgConfig::new(100, 100).with_preserve_aspect_ratio(false);
1283            let exporter = SvgExporter::with_config(config);
1284            let shapes = vec![SvgShape::rect(0.0, 0.0, 50.0, 50.0)];
1285            let svg = exporter.from_shapes(&shapes).unwrap();
1286
1287            assert!(svg.contains("preserveAspectRatio=\"none\""));
1288        }
1289
1290        #[test]
1291        fn test_shapes_with_title_and_description() {
1292            let config = SvgConfig::new(100, 100)
1293                .with_title("My Shapes")
1294                .with_description("A test shape");
1295            let exporter = SvgExporter::with_config(config);
1296            let shapes = vec![SvgShape::rect(0.0, 0.0, 50.0, 50.0)];
1297            let svg = exporter.from_shapes(&shapes).unwrap();
1298
1299            assert!(svg.contains("<title>My Shapes</title>"));
1300            assert!(svg.contains("<desc>A test shape</desc>"));
1301        }
1302    }
1303
1304    mod annotation_tests {
1305        use super::*;
1306
1307        #[test]
1308        fn test_all_annotation_types() {
1309            let screenshot = test_screenshot();
1310            let annotations = vec![
1311                Annotation::rectangle(10, 10, 50, 30),
1312                Annotation::highlight(60, 10, 50, 30),
1313                Annotation::circle(120, 25, 15),
1314                Annotation::arrow(150, 25, 50, 0),
1315                Annotation::filled_rectangle(10, 60, 50, 30),
1316            ];
1317
1318            let exporter = SvgExporter::with_config(SvgConfig::new(200, 200));
1319            let svg = exporter
1320                .from_screenshot_with_annotations(&screenshot, &annotations)
1321                .unwrap();
1322
1323            // Check all annotation types are rendered
1324            assert!(svg.contains("<rect")); // Rectangle, FilledRectangle, and Highlight
1325            assert!(svg.contains("<circle"));
1326            assert!(svg.contains("<line")); // Arrow
1327            assert!(svg.contains("<marker")); // Arrow marker
1328        }
1329
1330        #[test]
1331        fn test_annotation_with_label() {
1332            let screenshot = test_screenshot();
1333            let annotations = vec![Annotation::rectangle(10, 20, 50, 30).with_label("Test Label")];
1334
1335            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1336            let svg = exporter
1337                .from_screenshot_with_annotations(&screenshot, &annotations)
1338                .unwrap();
1339
1340            assert!(svg.contains("<text"));
1341            assert!(svg.contains("Test Label"));
1342        }
1343    }
1344
1345    mod helper_tests {
1346        use super::*;
1347
1348        #[test]
1349        fn test_escape_xml() {
1350            assert_eq!(escape_xml("<>&\"'"), "&lt;&gt;&amp;&quot;&apos;");
1351            assert_eq!(escape_xml("normal text"), "normal text");
1352            assert_eq!(
1353                escape_xml("<script>alert('xss')</script>"),
1354                "&lt;script&gt;alert(&apos;xss&apos;)&lt;/script&gt;"
1355            );
1356        }
1357
1358        #[test]
1359        fn test_color_to_svg() {
1360            assert_eq!(color_to_svg(&[255, 0, 0, 255]), "rgba(255,0,0,1)");
1361            assert_eq!(
1362                color_to_svg(&[0, 255, 0, 128]),
1363                "rgba(0,255,0,0.5019607843137255)"
1364            );
1365            assert_eq!(color_to_svg(&[0, 0, 0, 0]), "rgba(0,0,0,0)");
1366        }
1367
1368        #[test]
1369        fn test_base64_encode() {
1370            assert_eq!(base64_encode(b""), "");
1371            assert_eq!(base64_encode(b"f"), "Zg==");
1372            assert_eq!(base64_encode(b"fo"), "Zm8=");
1373            assert_eq!(base64_encode(b"foo"), "Zm9v");
1374            assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
1375            assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
1376            assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
1377        }
1378
1379        #[test]
1380        fn test_base64_encode_binary() {
1381            let data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
1382            let encoded = base64_encode(&data);
1383            assert!(!encoded.is_empty());
1384            // PNG magic bytes in base64
1385            assert!(encoded.starts_with("iVBORw"));
1386        }
1387    }
1388
1389    mod property_tests {
1390        use super::*;
1391
1392        #[test]
1393        fn prop_viewbox_matches_config() {
1394            for width in [100, 800, 1920, 4096] {
1395                for height in [100, 600, 1080, 2160] {
1396                    let config = SvgConfig::new(width, height);
1397                    let exporter = SvgExporter::with_config(config);
1398                    let screenshot = Screenshot {
1399                        data: vec![0],
1400                        width,
1401                        height,
1402                        device_pixel_ratio: 1.0,
1403                        timestamp: SystemTime::now(),
1404                    };
1405                    let svg = exporter.from_screenshot(&screenshot).unwrap();
1406
1407                    assert!(svg.contains(&format!("width=\"{width}\"")));
1408                    assert!(svg.contains(&format!("height=\"{height}\"")));
1409                    assert!(svg.contains(&format!("viewBox=\"0 0 {width} {height}\"")));
1410                }
1411            }
1412        }
1413
1414        #[test]
1415        fn prop_svg_always_valid_xml() {
1416            let screenshot = test_screenshot();
1417            let exporter = SvgExporter::new();
1418            let svg = exporter.from_screenshot(&screenshot).unwrap();
1419
1420            // Basic XML validity checks
1421            assert!(svg.starts_with("<?xml") || svg.starts_with("<svg"));
1422            assert!(svg.contains("</svg>"));
1423            assert_eq!(svg.matches("<svg").count(), 1);
1424            assert_eq!(svg.matches("</svg>").count(), 1);
1425        }
1426    }
1427
1428    mod shape_builder_tests {
1429        use super::*;
1430
1431        #[test]
1432        fn test_rect_with_stroke() {
1433            let shape = SvgShape::rect(0.0, 0.0, 100.0, 50.0)
1434                .with_stroke("red")
1435                .with_stroke_width(2.0);
1436
1437            let shapes = vec![shape];
1438            let exporter = SvgExporter::with_config(SvgConfig::new(200, 100));
1439            let svg = exporter.from_shapes(&shapes).unwrap();
1440
1441            assert!(svg.contains("stroke=\"red\""));
1442            assert!(svg.contains("stroke-width=\"2\""));
1443        }
1444
1445        #[test]
1446        fn test_circle_with_all_properties() {
1447            let shape = SvgShape::circle(50.0, 50.0, 25.0)
1448                .with_fill("blue")
1449                .with_stroke("black")
1450                .with_stroke_width(3.0);
1451
1452            let shapes = vec![shape];
1453            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1454            let svg = exporter.from_shapes(&shapes).unwrap();
1455
1456            assert!(svg.contains("fill=\"blue\""));
1457            assert!(svg.contains("stroke=\"black\""));
1458            assert!(svg.contains("stroke-width=\"3\""));
1459        }
1460
1461        #[test]
1462        fn test_line_with_stroke() {
1463            let shape = SvgShape::line(0.0, 0.0, 100.0, 100.0)
1464                .with_stroke("green")
1465                .with_stroke_width(5.0);
1466
1467            let shapes = vec![shape];
1468            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1469            let svg = exporter.from_shapes(&shapes).unwrap();
1470
1471            assert!(svg.contains("stroke=\"green\""));
1472        }
1473
1474        #[test]
1475        fn test_text_with_fill() {
1476            let shape = SvgShape::text(10.0, 50.0, "Hello World").with_fill("purple");
1477
1478            let shapes = vec![shape];
1479            let exporter = SvgExporter::with_config(SvgConfig::new(200, 100));
1480            let svg = exporter.from_shapes(&shapes).unwrap();
1481
1482            assert!(svg.contains("fill=\"purple\""));
1483            assert!(svg.contains("Hello World"));
1484        }
1485
1486        #[test]
1487        fn test_line_ignores_fill() {
1488            // Fill should be ignored for lines
1489            let shape = SvgShape::line(0.0, 0.0, 100.0, 100.0).with_fill("red");
1490
1491            let shapes = vec![shape];
1492            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1493            let svg = exporter.from_shapes(&shapes).unwrap();
1494
1495            // Line should not have fill attribute
1496            assert!(svg.contains("<line"));
1497        }
1498
1499        #[test]
1500        fn test_group_ignores_style_methods() {
1501            // Group should ignore fill/stroke/stroke_width
1502            let child = SvgShape::rect(0.0, 0.0, 50.0, 50.0).with_fill("blue");
1503            let shape = SvgShape::Group {
1504                id: Some("test".to_string()),
1505                children: vec![child],
1506            };
1507
1508            let modified = shape
1509                .with_fill("red")
1510                .with_stroke("green")
1511                .with_stroke_width(5.0);
1512
1513            let shapes = vec![modified];
1514            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1515            let svg = exporter.from_shapes(&shapes).unwrap();
1516
1517            // Group itself shouldn't have fill, child should have blue
1518            assert!(svg.contains("fill=\"blue\""));
1519        }
1520
1521        #[test]
1522        fn test_ellipse_with_all_properties() {
1523            let shape = SvgShape::Ellipse {
1524                cx: 50.0,
1525                cy: 50.0,
1526                rx: 40.0,
1527                ry: 20.0,
1528                fill: None,
1529                stroke: None,
1530                stroke_width: None,
1531            };
1532
1533            let shape = shape
1534                .with_fill("orange")
1535                .with_stroke("brown")
1536                .with_stroke_width(2.0);
1537
1538            let shapes = vec![shape];
1539            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1540            let svg = exporter.from_shapes(&shapes).unwrap();
1541
1542            assert!(svg.contains("fill=\"orange\""));
1543            assert!(svg.contains("stroke=\"brown\""));
1544        }
1545
1546        #[test]
1547        fn test_polygon_with_fill() {
1548            let shape = SvgShape::Polygon {
1549                points: vec![(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)],
1550                fill: None,
1551                stroke: None,
1552                stroke_width: None,
1553            }
1554            .with_fill("yellow");
1555
1556            let shapes = vec![shape];
1557            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1558            let svg = exporter.from_shapes(&shapes).unwrap();
1559
1560            assert!(svg.contains("fill=\"yellow\""));
1561        }
1562
1563        #[test]
1564        fn test_path_with_stroke() {
1565            let shape = SvgShape::Path {
1566                d: "M 0 0 L 100 100".to_string(),
1567                fill: None,
1568                stroke: None,
1569                stroke_width: None,
1570            }
1571            .with_stroke("gray")
1572            .with_stroke_width(4.0);
1573
1574            let shapes = vec![shape];
1575            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1576            let svg = exporter.from_shapes(&shapes).unwrap();
1577
1578            assert!(svg.contains("stroke=\"gray\""));
1579            assert!(svg.contains("stroke-width=\"4\""));
1580        }
1581
1582        #[test]
1583        fn test_polyline_with_stroke() {
1584            let shape = SvgShape::Polyline {
1585                points: vec![(0.0, 0.0), (50.0, 100.0), (100.0, 0.0)],
1586                stroke: None,
1587                stroke_width: None,
1588                fill: None,
1589            }
1590            .with_stroke("navy")
1591            .with_fill("none");
1592
1593            let shapes = vec![shape];
1594            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1595            let svg = exporter.from_shapes(&shapes).unwrap();
1596
1597            assert!(svg.contains("stroke=\"navy\""));
1598        }
1599    }
1600
1601    mod annotation_rendering_tests {
1602        use super::*;
1603
1604        #[test]
1605        fn test_annotation_with_custom_color() {
1606            let screenshot = test_screenshot();
1607            let annotations =
1608                vec![Annotation::rectangle(10, 10, 50, 50).with_color(0, 255, 0, 255)];
1609
1610            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1611            let svg = exporter
1612                .from_screenshot_with_annotations(&screenshot, &annotations)
1613                .unwrap();
1614
1615            assert!(svg.contains("rgba(0,255,0,1)"));
1616        }
1617
1618        #[test]
1619        fn test_annotation_with_label() {
1620            let screenshot = test_screenshot();
1621            let annotations = vec![Annotation::rectangle(10, 10, 50, 50).with_label("Test")];
1622
1623            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1624            let svg = exporter
1625                .from_screenshot_with_annotations(&screenshot, &annotations)
1626                .unwrap();
1627
1628            assert!(svg.contains("Test"));
1629        }
1630    }
1631
1632    mod exporter_clone_debug_tests {
1633        use super::*;
1634
1635        #[test]
1636        fn test_svg_config_debug() {
1637            let config = SvgConfig::new(800, 600);
1638            let debug = format!("{:?}", config);
1639            assert!(debug.contains("SvgConfig"));
1640        }
1641
1642        #[test]
1643        fn test_svg_exporter_debug() {
1644            let exporter = SvgExporter::new();
1645            let debug = format!("{:?}", exporter);
1646            assert!(debug.contains("SvgExporter"));
1647        }
1648
1649        #[test]
1650        fn test_svg_shape_debug() {
1651            let shape = SvgShape::rect(0.0, 0.0, 100.0, 50.0);
1652            let debug = format!("{:?}", shape);
1653            assert!(debug.contains("Rect"));
1654        }
1655    }
1656
1657    // Additional tests to increase coverage to 95%+
1658    mod additional_coverage_tests {
1659        use super::*;
1660        use std::path::PathBuf;
1661        use tempfile::tempdir;
1662
1663        #[test]
1664        fn test_svg_exporter_default_trait() {
1665            // Explicitly test Default trait implementation
1666            let exporter = SvgExporter::default();
1667            let config = exporter.config();
1668            assert_eq!(config.viewbox, (800, 600));
1669            assert!(config.preserve_aspect_ratio);
1670        }
1671
1672        #[test]
1673        fn test_svg_config_clone() {
1674            let config = SvgConfig::new(1024, 768)
1675                .with_title("Test Title")
1676                .with_description("Test Desc");
1677            let cloned = config;
1678            assert_eq!(cloned.viewbox, (1024, 768));
1679            assert_eq!(cloned.title, Some("Test Title".to_string()));
1680            assert_eq!(cloned.description, Some("Test Desc".to_string()));
1681        }
1682
1683        #[test]
1684        fn test_svg_exporter_clone() {
1685            let exporter = SvgExporter::new();
1686            let cloned = exporter.clone();
1687            assert_eq!(cloned.config().viewbox, exporter.config().viewbox);
1688        }
1689
1690        #[test]
1691        fn test_svg_shape_clone() {
1692            let shape = SvgShape::rect(10.0, 20.0, 100.0, 50.0).with_fill("blue");
1693            let cloned = shape;
1694            if let SvgShape::Rect { x, y, fill, .. } = cloned {
1695                assert!((x - 10.0).abs() < f64::EPSILON);
1696                assert!((y - 20.0).abs() < f64::EPSILON);
1697                assert_eq!(fill, Some("blue".to_string()));
1698            } else {
1699                panic!("Expected Rect shape");
1700            }
1701        }
1702
1703        #[test]
1704        fn test_svg_compression_copy_eq() {
1705            let comp1 = SvgCompression::None;
1706            let comp2 = comp1; // Copy
1707            assert_eq!(comp1, comp2);
1708
1709            let comp3 = SvgCompression::Minified;
1710            assert_ne!(comp1, comp3);
1711        }
1712
1713        #[test]
1714        fn test_svg_compression_default() {
1715            let default = SvgCompression::default();
1716            assert_eq!(default, SvgCompression::None);
1717        }
1718
1719        #[test]
1720        fn test_save_to_file() {
1721            let dir = tempdir().unwrap();
1722            let path = dir.path().join("test.svg");
1723
1724            let exporter = SvgExporter::new();
1725            let shapes = vec![SvgShape::rect(0.0, 0.0, 100.0, 50.0)];
1726            let svg = exporter.from_shapes(&shapes).unwrap();
1727
1728            exporter.save(&svg, &path).unwrap();
1729
1730            let contents = std::fs::read_to_string(&path).unwrap();
1731            assert!(contents.contains("<svg"));
1732            assert!(contents.contains("<rect"));
1733        }
1734
1735        #[test]
1736        fn test_save_error_handling() {
1737            let exporter = SvgExporter::new();
1738            let svg = "<svg></svg>";
1739
1740            // Try to save to an invalid path
1741            let invalid_path = PathBuf::from("/nonexistent_dir_xyz/test.svg");
1742            let result = exporter.save(svg, &invalid_path);
1743            assert!(result.is_err());
1744        }
1745
1746        #[test]
1747        fn test_preserve_aspect_ratio_false_screenshot() {
1748            let screenshot = test_screenshot();
1749            let config = SvgConfig::new(100, 100).with_preserve_aspect_ratio(false);
1750            let exporter = SvgExporter::with_config(config);
1751
1752            let svg = exporter.from_screenshot(&screenshot).unwrap();
1753
1754            assert!(svg.contains("preserveAspectRatio=\"none\""));
1755        }
1756
1757        #[test]
1758        fn test_minified_shapes_output() {
1759            let config = SvgConfig::new(100, 100)
1760                .with_compression(SvgCompression::Minified)
1761                .with_xml_declaration(false);
1762            let exporter = SvgExporter::with_config(config);
1763            let shapes = vec![SvgShape::rect(0.0, 0.0, 50.0, 50.0)];
1764
1765            let svg = exporter.from_shapes(&shapes).unwrap();
1766
1767            // Minified should not have indentation
1768            assert!(!svg.contains("\n  "));
1769        }
1770
1771        #[test]
1772        fn test_shapes_no_xml_declaration() {
1773            let config = SvgConfig::new(100, 100).with_xml_declaration(false);
1774            let exporter = SvgExporter::with_config(config);
1775            let shapes = vec![SvgShape::rect(0.0, 0.0, 50.0, 50.0)];
1776
1777            let svg = exporter.from_shapes(&shapes).unwrap();
1778
1779            assert!(!svg.contains("<?xml"));
1780        }
1781
1782        #[test]
1783        fn test_title_and_description_xml_escaping() {
1784            let screenshot = test_screenshot();
1785            let config = SvgConfig::new(100, 100)
1786                .with_title("Test <Title> & 'Quotes' \"Escaping\"")
1787                .with_description("Desc with <xml> & 'special' \"chars\"");
1788            let exporter = SvgExporter::with_config(config);
1789
1790            let svg = exporter.from_screenshot(&screenshot).unwrap();
1791
1792            assert!(svg.contains("&lt;Title&gt;"));
1793            assert!(svg.contains("&amp;"));
1794            assert!(svg.contains("&apos;"));
1795            assert!(svg.contains("&quot;"));
1796        }
1797
1798        #[test]
1799        fn test_empty_shapes_list() {
1800            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1801            let shapes: Vec<SvgShape> = vec![];
1802
1803            let svg = exporter.from_shapes(&shapes).unwrap();
1804
1805            assert!(svg.contains("<svg"));
1806            assert!(svg.contains("</svg>"));
1807        }
1808
1809        #[test]
1810        fn test_empty_annotations_list() {
1811            let screenshot = test_screenshot();
1812            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1813            let annotations: Vec<Annotation> = vec![];
1814
1815            let svg = exporter
1816                .from_screenshot_with_annotations(&screenshot, &annotations)
1817                .unwrap();
1818
1819            // Should not have annotations group when empty
1820            assert!(!svg.contains("<g id=\"annotations\">"));
1821        }
1822
1823        #[test]
1824        fn test_annotation_circle_with_label() {
1825            let screenshot = test_screenshot();
1826            let annotations = vec![Annotation::circle(50, 50, 20).with_label("Circle Label")];
1827
1828            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1829            let svg = exporter
1830                .from_screenshot_with_annotations(&screenshot, &annotations)
1831                .unwrap();
1832
1833            assert!(svg.contains("<circle"));
1834            assert!(svg.contains("Circle Label"));
1835        }
1836
1837        #[test]
1838        fn test_annotation_arrow_with_label() {
1839            let screenshot = test_screenshot();
1840            let annotations = vec![Annotation::arrow(10, 10, 50, 50).with_label("Arrow Label")];
1841
1842            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1843            let svg = exporter
1844                .from_screenshot_with_annotations(&screenshot, &annotations)
1845                .unwrap();
1846
1847            assert!(svg.contains("<line"));
1848            assert!(svg.contains("<marker"));
1849            assert!(svg.contains("Arrow Label"));
1850        }
1851
1852        #[test]
1853        fn test_annotation_highlight_with_label() {
1854            let screenshot = test_screenshot();
1855            let annotations =
1856                vec![Annotation::highlight(10, 10, 50, 30).with_label("Highlight Label")];
1857
1858            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1859            let svg = exporter
1860                .from_screenshot_with_annotations(&screenshot, &annotations)
1861                .unwrap();
1862
1863            assert!(svg.contains("fill-opacity=\"0.3\""));
1864            assert!(svg.contains("Highlight Label"));
1865        }
1866
1867        #[test]
1868        fn test_annotation_filled_rectangle_with_label() {
1869            let screenshot = test_screenshot();
1870            let annotations =
1871                vec![Annotation::filled_rectangle(10, 10, 50, 30).with_label("Filled Label")];
1872
1873            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1874            let svg = exporter
1875                .from_screenshot_with_annotations(&screenshot, &annotations)
1876                .unwrap();
1877
1878            assert!(svg.contains("<rect"));
1879            assert!(svg.contains("Filled Label"));
1880        }
1881
1882        #[test]
1883        fn test_minified_annotations() {
1884            let screenshot = test_screenshot();
1885            let config = SvgConfig::new(100, 100).with_compression(SvgCompression::Minified);
1886            let annotations = vec![Annotation::rectangle(10, 10, 50, 30)];
1887            let exporter = SvgExporter::with_config(config);
1888
1889            let svg = exporter
1890                .from_screenshot_with_annotations(&screenshot, &annotations)
1891                .unwrap();
1892
1893            // Minified should have no indented newlines
1894            assert!(!svg.contains("\n  "));
1895        }
1896
1897        #[test]
1898        fn test_base64_encode_all_chunk_sizes() {
1899            // Test chunk size 1
1900            assert_eq!(base64_encode(&[65]), "QQ==");
1901            // Test chunk size 2
1902            assert_eq!(base64_encode(&[65, 66]), "QUI=");
1903            // Test chunk size 3
1904            assert_eq!(base64_encode(&[65, 66, 67]), "QUJD");
1905            // Test longer data with all chunk sizes represented
1906            let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
1907            let encoded = base64_encode(&data);
1908            assert!(!encoded.is_empty());
1909            assert!(!encoded.contains(' '));
1910        }
1911
1912        #[test]
1913        fn test_base64_encode_large_data() {
1914            let data: Vec<u8> = (0..=255).collect();
1915            let encoded = base64_encode(&data);
1916            assert!(!encoded.is_empty());
1917            // Should be properly padded
1918            assert!(encoded.len() % 4 == 0);
1919        }
1920
1921        #[test]
1922        fn test_text_ignores_stroke() {
1923            // Text should ignore stroke and stroke_width
1924            let shape = SvgShape::text(10.0, 20.0, "Hello")
1925                .with_stroke("red")
1926                .with_stroke_width(2.0);
1927
1928            let shapes = vec![shape];
1929            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1930            let svg = exporter.from_shapes(&shapes).unwrap();
1931
1932            // Text element should exist but not have stroke attributes
1933            assert!(svg.contains("<text"));
1934            // Verify no stroke attribute in the text element
1935            let text_line = svg.lines().find(|l| l.contains("<text")).unwrap();
1936            assert!(!text_line.contains("stroke="));
1937        }
1938
1939        #[test]
1940        fn test_polygon_without_optional_attrs() {
1941            let shapes = vec![SvgShape::Polygon {
1942                points: vec![(0.0, 0.0), (50.0, 50.0), (100.0, 0.0)],
1943                fill: None,
1944                stroke: None,
1945                stroke_width: None,
1946            }];
1947
1948            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1949            let svg = exporter.from_shapes(&shapes).unwrap();
1950
1951            assert!(svg.contains("<polygon"));
1952            // Should not have fill, stroke, or stroke-width when None
1953            let polygon_line = svg.lines().find(|l| l.contains("<polygon")).unwrap();
1954            assert!(!polygon_line.contains("fill="));
1955            assert!(!polygon_line.contains("stroke="));
1956        }
1957
1958        #[test]
1959        fn test_path_without_optional_attrs() {
1960            let shapes = vec![SvgShape::Path {
1961                d: "M0 0 L100 100".to_string(),
1962                fill: None,
1963                stroke: None,
1964                stroke_width: None,
1965            }];
1966
1967            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1968            let svg = exporter.from_shapes(&shapes).unwrap();
1969
1970            assert!(svg.contains("<path"));
1971            assert!(svg.contains("d=\"M0 0 L100 100\""));
1972        }
1973
1974        #[test]
1975        fn test_ellipse_without_optional_attrs() {
1976            let shapes = vec![SvgShape::Ellipse {
1977                cx: 50.0,
1978                cy: 50.0,
1979                rx: 40.0,
1980                ry: 20.0,
1981                fill: None,
1982                stroke: None,
1983                stroke_width: None,
1984            }];
1985
1986            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
1987            let svg = exporter.from_shapes(&shapes).unwrap();
1988
1989            assert!(svg.contains("<ellipse"));
1990        }
1991
1992        #[test]
1993        fn test_line_without_optional_attrs() {
1994            let shapes = vec![SvgShape::Line {
1995                x1: 0.0,
1996                y1: 0.0,
1997                x2: 100.0,
1998                y2: 100.0,
1999                stroke: None,
2000                stroke_width: None,
2001            }];
2002
2003            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2004            let svg = exporter.from_shapes(&shapes).unwrap();
2005
2006            assert!(svg.contains("<line"));
2007        }
2008
2009        #[test]
2010        fn test_circle_without_optional_attrs() {
2011            let shapes = vec![SvgShape::Circle {
2012                cx: 50.0,
2013                cy: 50.0,
2014                r: 25.0,
2015                fill: None,
2016                stroke: None,
2017                stroke_width: None,
2018            }];
2019
2020            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2021            let svg = exporter.from_shapes(&shapes).unwrap();
2022
2023            assert!(svg.contains("<circle"));
2024        }
2025
2026        #[test]
2027        fn test_rect_without_optional_attrs() {
2028            let shapes = vec![SvgShape::Rect {
2029                x: 10.0,
2030                y: 10.0,
2031                width: 80.0,
2032                height: 60.0,
2033                fill: None,
2034                stroke: None,
2035                stroke_width: None,
2036                rx: None,
2037                ry: None,
2038            }];
2039
2040            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2041            let svg = exporter.from_shapes(&shapes).unwrap();
2042
2043            assert!(svg.contains("<rect"));
2044        }
2045
2046        #[test]
2047        fn test_text_without_optional_attrs() {
2048            let shapes = vec![SvgShape::Text {
2049                x: 10.0,
2050                y: 20.0,
2051                content: "Plain text".to_string(),
2052                font_size: None,
2053                fill: None,
2054                font_family: None,
2055            }];
2056
2057            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2058            let svg = exporter.from_shapes(&shapes).unwrap();
2059
2060            assert!(svg.contains("<text"));
2061            assert!(svg.contains(">Plain text</text>"));
2062        }
2063
2064        #[test]
2065        fn test_nested_groups() {
2066            let inner_group = SvgShape::Group {
2067                id: Some("inner".to_string()),
2068                children: vec![SvgShape::circle(25.0, 25.0, 10.0).with_fill("red")],
2069            };
2070
2071            let outer_group = SvgShape::Group {
2072                id: Some("outer".to_string()),
2073                children: vec![inner_group],
2074            };
2075
2076            let shapes = vec![outer_group];
2077            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2078            let svg = exporter.from_shapes(&shapes).unwrap();
2079
2080            assert!(svg.contains("<g id=\"outer\">"));
2081            assert!(svg.contains("<g id=\"inner\">"));
2082            assert!(svg.contains("</g>"));
2083        }
2084
2085        #[test]
2086        fn test_group_minified() {
2087            let config = SvgConfig::new(100, 100).with_compression(SvgCompression::Minified);
2088            let exporter = SvgExporter::with_config(config);
2089
2090            let group = SvgShape::Group {
2091                id: Some("test".to_string()),
2092                children: vec![SvgShape::rect(0.0, 0.0, 50.0, 50.0)],
2093            };
2094
2095            let svg = exporter.from_shapes(&vec![group]).unwrap();
2096
2097            assert!(svg.contains("<g id=\"test\">"));
2098        }
2099
2100        #[test]
2101        fn test_escape_xml_edge_cases() {
2102            // Empty string
2103            assert_eq!(escape_xml(""), "");
2104            // Only special chars
2105            assert_eq!(escape_xml("<>&\"'"), "&lt;&gt;&amp;&quot;&apos;");
2106            // Unicode
2107            assert_eq!(escape_xml("Hello\u{00A0}World"), "Hello\u{00A0}World");
2108            // Mixed content
2109            assert_eq!(
2110                escape_xml("a < b > c & d \"e\" 'f'"),
2111                "a &lt; b &gt; c &amp; d &quot;e&quot; &apos;f&apos;"
2112            );
2113        }
2114
2115        #[test]
2116        fn test_color_to_svg_edge_cases() {
2117            // Full transparency
2118            assert_eq!(color_to_svg(&[255, 255, 255, 0]), "rgba(255,255,255,0)");
2119            // Half transparency
2120            let half = color_to_svg(&[100, 100, 100, 127]);
2121            assert!(half.starts_with("rgba(100,100,100,0."));
2122            // Full opacity
2123            assert_eq!(color_to_svg(&[0, 0, 0, 255]), "rgba(0,0,0,1)");
2124        }
2125
2126        #[test]
2127        fn test_shapes_with_special_characters() {
2128            let shapes = vec![SvgShape::text(10.0, 20.0, "Hello <World> & 'Friends'")];
2129
2130            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2131            let svg = exporter.from_shapes(&shapes).unwrap();
2132
2133            assert!(svg.contains("Hello &lt;World&gt; &amp; &apos;Friends&apos;"));
2134        }
2135
2136        #[test]
2137        fn test_polyline_stroke_width() {
2138            let shapes = vec![SvgShape::Polyline {
2139                points: vec![(0.0, 0.0), (50.0, 50.0), (100.0, 0.0)],
2140                stroke: Some("blue".to_string()),
2141                stroke_width: Some(3.0),
2142                fill: Some("none".to_string()),
2143            }];
2144
2145            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2146            let svg = exporter.from_shapes(&shapes).unwrap();
2147
2148            assert!(svg.contains("stroke=\"blue\""));
2149            assert!(svg.contains("stroke-width=\"3\""));
2150        }
2151
2152        #[test]
2153        fn test_polygon_stroke() {
2154            let shapes = vec![SvgShape::Polygon {
2155                points: vec![(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)],
2156                fill: Some("yellow".to_string()),
2157                stroke: Some("black".to_string()),
2158                stroke_width: Some(2.0),
2159            }];
2160
2161            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2162            let svg = exporter.from_shapes(&shapes).unwrap();
2163
2164            assert!(svg.contains("stroke=\"black\""));
2165            assert!(svg.contains("stroke-width=\"2\""));
2166        }
2167
2168        #[test]
2169        fn test_path_fill() {
2170            let shapes = vec![SvgShape::Path {
2171                d: "M10 10 L90 90".to_string(),
2172                fill: Some("green".to_string()),
2173                stroke: None,
2174                stroke_width: None,
2175            }];
2176
2177            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2178            let svg = exporter.from_shapes(&shapes).unwrap();
2179
2180            assert!(svg.contains("fill=\"green\""));
2181        }
2182
2183        #[test]
2184        fn test_with_fill_on_path() {
2185            let shape = SvgShape::Path {
2186                d: "M0 0".to_string(),
2187                fill: None,
2188                stroke: None,
2189                stroke_width: None,
2190            }
2191            .with_fill("magenta");
2192
2193            let shapes = vec![shape];
2194            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2195            let svg = exporter.from_shapes(&shapes).unwrap();
2196
2197            assert!(svg.contains("fill=\"magenta\""));
2198        }
2199
2200        #[test]
2201        fn test_with_stroke_on_polygon() {
2202            let shape = SvgShape::Polygon {
2203                points: vec![(0.0, 0.0), (50.0, 50.0), (100.0, 0.0)],
2204                fill: None,
2205                stroke: None,
2206                stroke_width: None,
2207            }
2208            .with_stroke("cyan");
2209
2210            let shapes = vec![shape];
2211            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2212            let svg = exporter.from_shapes(&shapes).unwrap();
2213
2214            assert!(svg.contains("stroke=\"cyan\""));
2215        }
2216
2217        #[test]
2218        fn test_with_stroke_width_on_polyline() {
2219            let shape = SvgShape::Polyline {
2220                points: vec![(0.0, 0.0), (100.0, 100.0)],
2221                stroke: None,
2222                stroke_width: None,
2223                fill: None,
2224            }
2225            .with_stroke_width(5.0);
2226
2227            let shapes = vec![shape];
2228            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2229            let svg = exporter.from_shapes(&shapes).unwrap();
2230
2231            assert!(svg.contains("stroke-width=\"5\""));
2232        }
2233
2234        #[test]
2235        fn test_rect_only_rx() {
2236            let shapes = vec![SvgShape::Rect {
2237                x: 10.0,
2238                y: 10.0,
2239                width: 80.0,
2240                height: 60.0,
2241                fill: None,
2242                stroke: None,
2243                stroke_width: None,
2244                rx: Some(5.0),
2245                ry: None,
2246            }];
2247
2248            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2249            let svg = exporter.from_shapes(&shapes).unwrap();
2250
2251            assert!(svg.contains("rx=\"5\""));
2252            assert!(!svg.contains("ry="));
2253        }
2254
2255        #[test]
2256        fn test_rect_only_ry() {
2257            let shapes = vec![SvgShape::Rect {
2258                x: 10.0,
2259                y: 10.0,
2260                width: 80.0,
2261                height: 60.0,
2262                fill: None,
2263                stroke: None,
2264                stroke_width: None,
2265                rx: None,
2266                ry: Some(5.0),
2267            }];
2268
2269            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2270            let svg = exporter.from_shapes(&shapes).unwrap();
2271
2272            assert!(!svg.contains("rx="));
2273            assert!(svg.contains("ry=\"5\""));
2274        }
2275
2276        #[test]
2277        fn test_large_screenshot_data() {
2278            // Test with larger image data
2279            let large_data: Vec<u8> = (0..10000).map(|i| (i % 256) as u8).collect();
2280            let screenshot = Screenshot {
2281                data: large_data,
2282                width: 500,
2283                height: 500,
2284                device_pixel_ratio: 2.0,
2285                timestamp: SystemTime::now(),
2286            };
2287
2288            let exporter = SvgExporter::with_config(SvgConfig::new(500, 500));
2289            let svg = exporter.from_screenshot(&screenshot).unwrap();
2290
2291            assert!(svg.contains("data:image/png;base64,"));
2292            assert!(svg.contains("</svg>"));
2293        }
2294
2295        #[test]
2296        fn test_annotation_label_xml_escape() {
2297            let screenshot = test_screenshot();
2298            let annotations = vec![
2299                Annotation::rectangle(10, 10, 50, 30).with_label("Label with <xml> & 'chars'")
2300            ];
2301
2302            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2303            let svg = exporter
2304                .from_screenshot_with_annotations(&screenshot, &annotations)
2305                .unwrap();
2306
2307            assert!(svg.contains("&lt;xml&gt;"));
2308            assert!(svg.contains("&amp;"));
2309        }
2310
2311        #[test]
2312        fn test_multiple_annotations_same_type() {
2313            let screenshot = test_screenshot();
2314            let annotations = vec![
2315                Annotation::rectangle(10, 10, 20, 20),
2316                Annotation::rectangle(40, 40, 20, 20),
2317                Annotation::rectangle(70, 70, 20, 20),
2318            ];
2319
2320            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2321            let svg = exporter
2322                .from_screenshot_with_annotations(&screenshot, &annotations)
2323                .unwrap();
2324
2325            // Should have 3 rectangles (image uses <image> element, not rect)
2326            assert_eq!(svg.matches("<rect x=\"").count(), 3);
2327        }
2328
2329        #[test]
2330        fn test_all_shapes_in_single_svg() {
2331            let shapes = vec![
2332                SvgShape::rect(10.0, 10.0, 30.0, 20.0),
2333                SvgShape::circle(50.0, 50.0, 15.0),
2334                SvgShape::line(0.0, 0.0, 100.0, 100.0),
2335                SvgShape::text(10.0, 90.0, "Text"),
2336                SvgShape::Ellipse {
2337                    cx: 80.0,
2338                    cy: 20.0,
2339                    rx: 10.0,
2340                    ry: 5.0,
2341                    fill: Some("pink".to_string()),
2342                    stroke: None,
2343                    stroke_width: None,
2344                },
2345                SvgShape::Polyline {
2346                    points: vec![(5.0, 5.0), (15.0, 15.0)],
2347                    stroke: Some("brown".to_string()),
2348                    stroke_width: None,
2349                    fill: None,
2350                },
2351                SvgShape::Polygon {
2352                    points: vec![(30.0, 30.0), (40.0, 40.0), (30.0, 40.0)],
2353                    fill: None,
2354                    stroke: None,
2355                    stroke_width: None,
2356                },
2357                SvgShape::Path {
2358                    d: "M50 50 L60 60".to_string(),
2359                    fill: None,
2360                    stroke: None,
2361                    stroke_width: None,
2362                },
2363                SvgShape::Group {
2364                    id: None,
2365                    children: vec![SvgShape::rect(0.0, 0.0, 10.0, 10.0)],
2366                },
2367            ];
2368
2369            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2370            let svg = exporter.from_shapes(&shapes).unwrap();
2371
2372            assert!(svg.contains("<rect"));
2373            assert!(svg.contains("<circle"));
2374            assert!(svg.contains("<line"));
2375            assert!(svg.contains("<text"));
2376            assert!(svg.contains("<ellipse"));
2377            assert!(svg.contains("<polyline"));
2378            assert!(svg.contains("<polygon"));
2379            assert!(svg.contains("<path"));
2380            assert!(svg.contains("<g>"));
2381        }
2382
2383        #[test]
2384        fn test_annotation_y_saturating_sub() {
2385            let screenshot = test_screenshot();
2386            // Create annotation with y=0 to test saturating_sub
2387            let annotations = vec![Annotation::rectangle(10, 0, 50, 30).with_label("At top")];
2388
2389            let exporter = SvgExporter::with_config(SvgConfig::new(100, 100));
2390            let svg = exporter
2391                .from_screenshot_with_annotations(&screenshot, &annotations)
2392                .unwrap();
2393
2394            // Label y position should be 0 (since 0 - 5 saturates to 0)
2395            assert!(svg.contains("y=\"0\""));
2396            assert!(svg.contains("At top"));
2397        }
2398
2399        #[test]
2400        fn test_compression_debug() {
2401            let comp = SvgCompression::Minified;
2402            let debug = format!("{:?}", comp);
2403            assert!(debug.contains("Minified"));
2404        }
2405    }
2406}