1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum SvgCompression {
22 #[default]
24 None,
25 Minified,
27}
28
29#[derive(Debug, Clone)]
31pub struct SvgConfig {
32 pub viewbox: (u32, u32),
34 pub preserve_aspect_ratio: bool,
36 pub embed_fonts: bool,
38 pub compression: SvgCompression,
40 pub include_xml_declaration: bool,
42 pub title: Option<String>,
44 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 #[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 #[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 #[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 #[must_use]
93 pub const fn with_compression(mut self, compression: SvgCompression) -> Self {
94 self.compression = compression;
95 self
96 }
97
98 #[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 #[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 #[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#[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 #[must_use]
135 pub fn new() -> Self {
136 Self {
137 config: SvgConfig::default(),
138 }
139 }
140
141 #[must_use]
143 pub const fn with_config(config: SvgConfig) -> Self {
144 Self { config }
145 }
146
147 #[must_use]
149 pub const fn config(&self) -> &SvgConfig {
150 &self.config
151 }
152
153 pub fn from_screenshot(&self, screenshot: &Screenshot) -> ProbarResult<String> {
162 self.from_screenshot_with_annotations(screenshot, &[])
163 }
164
165 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 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 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 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 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 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 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 svg.push_str("</svg>");
259 svg.push_str(newline);
260
261 Ok(svg)
262 }
263
264 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 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 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 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 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 for shape in shapes {
322 self.render_shape(&mut svg, shape, indent)?;
323 svg.push_str(newline);
324 }
325
326 svg.push_str("</svg>");
328 svg.push_str(newline);
329
330 Ok(svg)
331 }
332
333 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 fn newline(&self) -> &'static str {
346 match self.config.compression {
347 SvgCompression::None => "\n",
348 SvgCompression::Minified => "",
349 }
350 }
351
352 fn indent(&self) -> &'static str {
354 match self.config.compression {
355 SvgCompression::None => " ",
356 SvgCompression::Minified => "",
357 }
358 }
359
360 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 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 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 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 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 fn render_shape(&self, svg: &mut String, shape: &SvgShape, indent: &str) -> ProbarResult<()> {
463 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#[derive(Debug, Clone)]
675#[allow(missing_docs)]
676pub enum SvgShape {
677 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 {
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 {
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 {
710 x1: f64,
711 y1: f64,
712 x2: f64,
713 y2: f64,
714 stroke: Option<String>,
715 stroke_width: Option<f64>,
716 },
717 Polyline {
719 points: Vec<(f64, f64)>,
720 stroke: Option<String>,
721 stroke_width: Option<f64>,
722 fill: Option<String>,
723 },
724 Polygon {
726 points: Vec<(f64, f64)>,
727 fill: Option<String>,
728 stroke: Option<String>,
729 stroke_width: Option<f64>,
730 },
731 Path {
733 d: String,
734 fill: Option<String>,
735 stroke: Option<String>,
736 stroke_width: Option<f64>,
737 },
738 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 {
749 id: Option<String>,
750 children: Vec<SvgShape>,
751 },
752}
753
754impl SvgShape {
755 #[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 #[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 #[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 #[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 #[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 #[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 #[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
873fn 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
884fn escape_xml(s: &str) -> String {
886 s.replace('&', "&")
887 .replace('<', "<")
888 .replace('>', ">")
889 .replace('"', """)
890 .replace('\'', "'")
891}
892
893fn 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 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, _ => (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], 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 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 assert!(svg.contains("<rect")); assert!(svg.contains("<circle"));
1326 assert!(svg.contains("<line")); assert!(svg.contains("<marker")); }
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("<>&\"'"), "<>&"'");
1351 assert_eq!(escape_xml("normal text"), "normal text");
1352 assert_eq!(
1353 escape_xml("<script>alert('xss')</script>"),
1354 "<script>alert('xss')</script>"
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 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 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 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 assert!(svg.contains("<line"));
1497 }
1498
1499 #[test]
1500 fn test_group_ignores_style_methods() {
1501 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 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 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 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; 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 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 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("<Title>"));
1793 assert!(svg.contains("&"));
1794 assert!(svg.contains("'"));
1795 assert!(svg.contains("""));
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 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 assert!(!svg.contains("\n "));
1895 }
1896
1897 #[test]
1898 fn test_base64_encode_all_chunk_sizes() {
1899 assert_eq!(base64_encode(&[65]), "QQ==");
1901 assert_eq!(base64_encode(&[65, 66]), "QUI=");
1903 assert_eq!(base64_encode(&[65, 66, 67]), "QUJD");
1905 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 assert!(encoded.len() % 4 == 0);
1919 }
1920
1921 #[test]
1922 fn test_text_ignores_stroke() {
1923 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 assert!(svg.contains("<text"));
1934 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 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 assert_eq!(escape_xml(""), "");
2104 assert_eq!(escape_xml("<>&\"'"), "<>&"'");
2106 assert_eq!(escape_xml("Hello\u{00A0}World"), "Hello\u{00A0}World");
2108 assert_eq!(
2110 escape_xml("a < b > c & d \"e\" 'f'"),
2111 "a < b > c & d "e" 'f'"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_color_to_svg_edge_cases() {
2117 assert_eq!(color_to_svg(&[255, 255, 255, 0]), "rgba(255,255,255,0)");
2119 let half = color_to_svg(&[100, 100, 100, 127]);
2121 assert!(half.starts_with("rgba(100,100,100,0."));
2122 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 <World> & 'Friends'"));
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 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("<xml>"));
2308 assert!(svg.contains("&"));
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 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 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 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}