1use crate::driver::Screenshot;
8use crate::result::{ProbarError, ProbarResult};
9use image::{DynamicImage, GenericImageView, Rgba, RgbaImage};
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12use std::time::SystemTime;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16pub enum CompressionLevel {
17 None,
19 Fast,
21 #[default]
23 Default,
24 Best,
26}
27
28impl CompressionLevel {
29 fn to_png_compression(self) -> png::Compression {
31 match self {
32 Self::None | Self::Fast => png::Compression::Fast,
33 Self::Default => png::Compression::Default,
34 Self::Best => png::Compression::Best,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct PngMetadata {
42 pub title: Option<String>,
44 pub description: Option<String>,
46 pub timestamp: Option<SystemTime>,
48 pub test_name: Option<String>,
50 pub software: Option<String>,
52}
53
54impl PngMetadata {
55 #[must_use]
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 #[must_use]
63 pub fn with_title(mut self, title: impl Into<String>) -> Self {
64 self.title = Some(title.into());
65 self
66 }
67
68 #[must_use]
70 pub fn with_description(mut self, description: impl Into<String>) -> Self {
71 self.description = Some(description.into());
72 self
73 }
74
75 #[must_use]
77 pub fn with_timestamp(mut self, timestamp: SystemTime) -> Self {
78 self.timestamp = Some(timestamp);
79 self
80 }
81
82 #[must_use]
84 pub fn with_test_name(mut self, name: impl Into<String>) -> Self {
85 self.test_name = Some(name.into());
86 self
87 }
88
89 #[must_use]
91 pub fn with_software(mut self, software: impl Into<String>) -> Self {
92 self.software = Some(software.into());
93 self
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Annotation {
100 pub x: u32,
102 pub y: u32,
104 pub width: u32,
106 pub height: u32,
108 pub color: [u8; 4],
110 pub kind: AnnotationKind,
112 pub label: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub enum AnnotationKind {
119 Rectangle,
121 FilledRectangle,
123 Circle,
125 Arrow,
127 Highlight,
129}
130
131impl Annotation {
132 #[must_use]
134 pub fn rectangle(x: u32, y: u32, width: u32, height: u32) -> Self {
135 Self {
136 x,
137 y,
138 width,
139 height,
140 color: [255, 0, 0, 255], kind: AnnotationKind::Rectangle,
142 label: None,
143 }
144 }
145
146 #[must_use]
148 pub fn highlight(x: u32, y: u32, width: u32, height: u32) -> Self {
149 Self {
150 x,
151 y,
152 width,
153 height,
154 color: [255, 255, 0, 128], kind: AnnotationKind::Highlight,
156 label: None,
157 }
158 }
159
160 #[must_use]
162 pub fn filled_rectangle(x: u32, y: u32, width: u32, height: u32) -> Self {
163 Self {
164 x,
165 y,
166 width,
167 height,
168 color: [255, 0, 0, 255], kind: AnnotationKind::FilledRectangle,
170 label: None,
171 }
172 }
173
174 #[must_use]
176 pub fn circle(x: u32, y: u32, diameter: u32) -> Self {
177 Self {
178 x,
179 y,
180 width: diameter,
181 height: diameter,
182 color: [0, 255, 0, 255], kind: AnnotationKind::Circle,
184 label: None,
185 }
186 }
187
188 #[must_use]
190 pub fn arrow(x: u32, y: u32, dx: u32, dy: u32) -> Self {
191 Self {
192 x,
193 y,
194 width: dx,
195 height: dy,
196 color: [0, 0, 255, 255], kind: AnnotationKind::Arrow,
198 label: None,
199 }
200 }
201
202 #[must_use]
204 pub fn with_color(mut self, r: u8, g: u8, b: u8, a: u8) -> Self {
205 self.color = [r, g, b, a];
206 self
207 }
208
209 #[must_use]
211 pub fn with_label(mut self, label: impl Into<String>) -> Self {
212 self.label = Some(label.into());
213 self
214 }
215}
216
217#[derive(Debug, Clone)]
230pub struct PngExporter {
231 compression: CompressionLevel,
232 metadata: PngMetadata,
233}
234
235impl Default for PngExporter {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241impl PngExporter {
242 #[must_use]
244 pub fn new() -> Self {
245 Self {
246 compression: CompressionLevel::Default,
247 metadata: PngMetadata::new().with_software("Probar".to_string()),
248 }
249 }
250
251 #[must_use]
253 pub fn with_compression(mut self, compression: CompressionLevel) -> Self {
254 self.compression = compression;
255 self
256 }
257
258 #[must_use]
260 pub fn with_metadata(mut self, metadata: PngMetadata) -> Self {
261 self.metadata = metadata;
262 self
263 }
264
265 #[must_use]
267 pub fn compression(&self) -> CompressionLevel {
268 self.compression
269 }
270
271 #[must_use]
273 pub fn metadata(&self) -> &PngMetadata {
274 &self.metadata
275 }
276
277 pub fn export(&self, screenshot: &Screenshot) -> ProbarResult<Vec<u8>> {
283 let img = image::load_from_memory(&screenshot.data).map_err(|e| {
285 ProbarError::ImageProcessing {
286 message: format!("Failed to decode screenshot: {e}"),
287 }
288 })?;
289
290 self.encode_png(&img)
291 }
292
293 pub fn export_with_annotations(
299 &self,
300 screenshot: &Screenshot,
301 annotations: &[Annotation],
302 ) -> ProbarResult<Vec<u8>> {
303 let img = image::load_from_memory(&screenshot.data).map_err(|e| {
305 ProbarError::ImageProcessing {
306 message: format!("Failed to decode screenshot: {e}"),
307 }
308 })?;
309
310 let mut rgba = img.to_rgba8();
311
312 for annotation in annotations {
314 Self::draw_annotation(&mut rgba, annotation);
315 }
316
317 self.encode_png(&DynamicImage::ImageRgba8(rgba))
318 }
319
320 pub fn save(&self, screenshot: &Screenshot, path: &Path) -> ProbarResult<()> {
326 let data = self.export(screenshot)?;
327 std::fs::write(path, data)?;
328 Ok(())
329 }
330
331 pub fn save_with_annotations(
337 &self,
338 screenshot: &Screenshot,
339 annotations: &[Annotation],
340 path: &Path,
341 ) -> ProbarResult<()> {
342 let data = self.export_with_annotations(screenshot, annotations)?;
343 std::fs::write(path, data)?;
344 Ok(())
345 }
346
347 fn encode_png(&self, img: &DynamicImage) -> ProbarResult<Vec<u8>> {
349 let (width, height) = img.dimensions();
350 let rgba = img.to_rgba8();
351
352 let mut output = Vec::new();
353
354 {
355 let mut encoder = png::Encoder::new(&mut output, width, height);
356 encoder.set_color(png::ColorType::Rgba);
357 encoder.set_depth(png::BitDepth::Eight);
358 encoder.set_compression(self.compression.to_png_compression());
359
360 let mut writer = encoder
361 .write_header()
362 .map_err(|e| ProbarError::ImageProcessing {
363 message: format!("Failed to write PNG header: {e}"),
364 })?;
365
366 writer
367 .write_image_data(&rgba)
368 .map_err(|e| ProbarError::ImageProcessing {
369 message: format!("Failed to write PNG data: {e}"),
370 })?;
371 }
372
373 Ok(output)
374 }
375
376 fn draw_annotation(img: &mut RgbaImage, annotation: &Annotation) {
378 let color = Rgba(annotation.color);
379
380 match annotation.kind {
381 AnnotationKind::Rectangle => {
382 Self::draw_rectangle_outline(img, annotation, color);
383 }
384 AnnotationKind::FilledRectangle => {
385 Self::draw_filled_rectangle(img, annotation, color);
386 }
387 AnnotationKind::Highlight => {
388 Self::draw_highlight(img, annotation);
389 }
390 AnnotationKind::Circle | AnnotationKind::Arrow => {
391 Self::draw_rectangle_outline(img, annotation, color);
393 }
394 }
395 }
396
397 fn draw_rectangle_outline(img: &mut RgbaImage, ann: &Annotation, color: Rgba<u8>) {
399 let (img_width, img_height) = img.dimensions();
400 let x_end = (ann.x + ann.width).min(img_width.saturating_sub(1));
401 let y_end = (ann.y + ann.height).min(img_height.saturating_sub(1));
402
403 for x in ann.x..=x_end {
405 if ann.y < img_height {
406 img.put_pixel(x, ann.y, color);
407 }
408 if y_end < img_height {
409 img.put_pixel(x, y_end, color);
410 }
411 }
412
413 for y in ann.y..=y_end {
415 if ann.x < img_width {
416 img.put_pixel(ann.x, y, color);
417 }
418 if x_end < img_width {
419 img.put_pixel(x_end, y, color);
420 }
421 }
422 }
423
424 fn draw_filled_rectangle(img: &mut RgbaImage, ann: &Annotation, color: Rgba<u8>) {
426 let (img_width, img_height) = img.dimensions();
427 let x_end = (ann.x + ann.width).min(img_width);
428 let y_end = (ann.y + ann.height).min(img_height);
429
430 for y in ann.y..y_end {
431 for x in ann.x..x_end {
432 img.put_pixel(x, y, color);
433 }
434 }
435 }
436
437 fn draw_highlight(img: &mut RgbaImage, ann: &Annotation) {
439 let (img_width, img_height) = img.dimensions();
440 let x_end = (ann.x + ann.width).min(img_width);
441 let y_end = (ann.y + ann.height).min(img_height);
442 let highlight_color = ann.color;
443 let alpha = f32::from(highlight_color[3]) / 255.0;
444
445 for y in ann.y..y_end {
446 for x in ann.x..x_end {
447 let pixel = img.get_pixel(x, y);
448 let blended = Rgba([
449 blend_channel(pixel[0], highlight_color[0], alpha),
450 blend_channel(pixel[1], highlight_color[1], alpha),
451 blend_channel(pixel[2], highlight_color[2], alpha),
452 255,
453 ]);
454 img.put_pixel(x, y, blended);
455 }
456 }
457 }
458}
459
460#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
462fn blend_channel(base: u8, overlay: u8, alpha: f32) -> u8 {
463 let result = f32::from(base).mul_add(1.0 - alpha, f32::from(overlay) * alpha);
464 result.clamp(0.0, 255.0) as u8
465}
466
467#[cfg(test)]
472#[allow(clippy::unwrap_used, clippy::expect_used)]
473mod tests {
474 use super::*;
475 use image::ImageFormat;
476 use std::io::Cursor;
477
478 fn create_test_screenshot(width: u32, height: u32, color: [u8; 4]) -> Screenshot {
479 let mut img = image::RgbaImage::new(width, height);
480 for pixel in img.pixels_mut() {
481 *pixel = Rgba(color);
482 }
483
484 let mut png_data = Vec::new();
485 img.write_to(&mut Cursor::new(&mut png_data), ImageFormat::Png)
486 .unwrap();
487
488 Screenshot::new(png_data, width, height)
489 }
490
491 mod compression_level_tests {
492 use super::*;
493
494 #[test]
495 fn test_default_compression() {
496 let level = CompressionLevel::default();
497 assert_eq!(level, CompressionLevel::Default);
498 }
499
500 #[test]
501 fn test_compression_levels() {
502 let _ = CompressionLevel::None;
504 let _ = CompressionLevel::Fast;
505 let _ = CompressionLevel::Default;
506 let _ = CompressionLevel::Best;
507 }
508 }
509
510 mod png_metadata_tests {
511 use super::*;
512
513 #[test]
514 fn test_default_metadata() {
515 let meta = PngMetadata::default();
516 assert!(meta.title.is_none());
517 assert!(meta.description.is_none());
518 assert!(meta.timestamp.is_none());
519 assert!(meta.test_name.is_none());
520 }
521
522 #[test]
523 fn test_with_title() {
524 let meta = PngMetadata::new().with_title("Test Screenshot");
525 assert_eq!(meta.title, Some("Test Screenshot".to_string()));
526 }
527
528 #[test]
529 fn test_with_description() {
530 let meta = PngMetadata::new().with_description("Login page after error");
531 assert_eq!(meta.description, Some("Login page after error".to_string()));
532 }
533
534 #[test]
535 fn test_with_test_name() {
536 let meta = PngMetadata::new().with_test_name("test_login_failure");
537 assert_eq!(meta.test_name, Some("test_login_failure".to_string()));
538 }
539
540 #[test]
541 fn test_chained_builders() {
542 let meta = PngMetadata::new()
543 .with_title("Title")
544 .with_description("Description")
545 .with_test_name("test_name")
546 .with_software("Probar Test");
547
548 assert_eq!(meta.title, Some("Title".to_string()));
549 assert_eq!(meta.description, Some("Description".to_string()));
550 assert_eq!(meta.test_name, Some("test_name".to_string()));
551 assert_eq!(meta.software, Some("Probar Test".to_string()));
552 }
553 }
554
555 mod annotation_tests {
556 use super::*;
557
558 #[test]
559 fn test_rectangle_annotation() {
560 let ann = Annotation::rectangle(10, 20, 100, 50);
561 assert_eq!(ann.x, 10);
562 assert_eq!(ann.y, 20);
563 assert_eq!(ann.width, 100);
564 assert_eq!(ann.height, 50);
565 assert!(matches!(ann.kind, AnnotationKind::Rectangle));
566 }
567
568 #[test]
569 fn test_highlight_annotation() {
570 let ann = Annotation::highlight(0, 0, 50, 50);
571 assert!(matches!(ann.kind, AnnotationKind::Highlight));
572 assert_eq!(ann.color[3], 128); }
574
575 #[test]
576 fn test_with_color() {
577 let ann = Annotation::rectangle(0, 0, 10, 10).with_color(0, 255, 0, 255);
578 assert_eq!(ann.color, [0, 255, 0, 255]);
579 }
580
581 #[test]
582 fn test_with_label() {
583 let ann = Annotation::rectangle(0, 0, 10, 10).with_label("Error Button");
584 assert_eq!(ann.label, Some("Error Button".to_string()));
585 }
586 }
587
588 mod png_exporter_tests {
589 use super::*;
590
591 #[test]
592 fn test_new_exporter() {
593 let exporter = PngExporter::new();
594 assert_eq!(exporter.compression(), CompressionLevel::Default);
595 }
596
597 #[test]
598 fn test_with_compression() {
599 let exporter = PngExporter::new().with_compression(CompressionLevel::Best);
600 assert_eq!(exporter.compression(), CompressionLevel::Best);
601 }
602
603 #[test]
604 fn test_with_metadata() {
605 let meta = PngMetadata::new().with_title("Test");
606 let exporter = PngExporter::new().with_metadata(meta);
607 assert_eq!(exporter.metadata().title, Some("Test".to_string()));
608 }
609
610 #[test]
611 fn test_export() {
612 let exporter = PngExporter::new();
613 let screenshot = create_test_screenshot(100, 100, [255, 0, 0, 255]);
614
615 let result = exporter.export(&screenshot);
616 assert!(result.is_ok());
617
618 let png_data = result.unwrap();
619 assert!(!png_data.is_empty());
620 assert_eq!(&png_data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
622 }
623
624 #[test]
625 fn test_export_with_annotations() {
626 let exporter = PngExporter::new();
627 let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
628
629 let annotations = vec![
630 Annotation::rectangle(10, 10, 30, 30).with_color(255, 0, 0, 255),
631 Annotation::highlight(50, 50, 40, 40),
632 ];
633
634 let result = exporter.export_with_annotations(&screenshot, &annotations);
635 assert!(result.is_ok());
636 }
637
638 #[test]
639 fn test_save() {
640 let exporter = PngExporter::new();
641 let screenshot = create_test_screenshot(50, 50, [0, 255, 0, 255]);
642
643 let temp_dir = tempfile::tempdir().unwrap();
644 let path = temp_dir.path().join("test.png");
645
646 let result = exporter.save(&screenshot, &path);
647 assert!(result.is_ok());
648 assert!(path.exists());
649
650 let data = std::fs::read(&path).unwrap();
652 assert_eq!(&data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
653 }
654
655 #[test]
656 fn test_save_with_annotations() {
657 let exporter = PngExporter::new();
658 let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
659 let annotations = vec![Annotation::rectangle(10, 10, 20, 20)];
660
661 let temp_dir = tempfile::tempdir().unwrap();
662 let path = temp_dir.path().join("annotated.png");
663
664 let result = exporter.save_with_annotations(&screenshot, &annotations, &path);
665 assert!(result.is_ok());
666 assert!(path.exists());
667 }
668
669 #[test]
670 fn test_compression_levels_produce_different_sizes() {
671 let screenshot = create_test_screenshot(200, 200, [128, 128, 128, 255]);
672
673 let fast = PngExporter::new()
674 .with_compression(CompressionLevel::Fast)
675 .export(&screenshot)
676 .unwrap();
677
678 let best = PngExporter::new()
679 .with_compression(CompressionLevel::Best)
680 .export(&screenshot)
681 .unwrap();
682
683 assert!(best.len() <= fast.len() + 100); }
687 }
688
689 mod blend_tests {
690 use super::*;
691
692 #[test]
693 fn test_blend_full_alpha() {
694 assert_eq!(blend_channel(100, 200, 1.0), 200);
696 }
697
698 #[test]
699 fn test_blend_zero_alpha() {
700 assert_eq!(blend_channel(100, 200, 0.0), 100);
702 }
703
704 #[test]
705 fn test_blend_half_alpha() {
706 let result = blend_channel(100, 200, 0.5);
708 assert!((145..=155).contains(&result)); }
710 }
711
712 mod annotation_kind_tests {
713 use super::*;
714
715 #[test]
716 fn test_filled_rectangle_annotation() {
717 let exporter = PngExporter::new();
718 let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
719
720 let annotations = vec![Annotation {
721 x: 10,
722 y: 10,
723 width: 30,
724 height: 30,
725 color: [0, 0, 255, 255],
726 kind: AnnotationKind::FilledRectangle,
727 label: None,
728 }];
729
730 let result = exporter.export_with_annotations(&screenshot, &annotations);
731 assert!(result.is_ok());
732 }
733
734 #[test]
735 fn test_circle_annotation() {
736 let exporter = PngExporter::new();
737 let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
738
739 let annotations = vec![Annotation {
740 x: 20,
741 y: 20,
742 width: 40,
743 height: 40,
744 color: [255, 0, 255, 255],
745 kind: AnnotationKind::Circle,
746 label: None,
747 }];
748
749 let result = exporter.export_with_annotations(&screenshot, &annotations);
750 assert!(result.is_ok());
751 }
752
753 #[test]
754 fn test_arrow_annotation() {
755 let exporter = PngExporter::new();
756 let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
757
758 let annotations = vec![Annotation {
759 x: 10,
760 y: 10,
761 width: 50,
762 height: 20,
763 color: [0, 255, 0, 255],
764 kind: AnnotationKind::Arrow,
765 label: Some("Click here".to_string()),
766 }];
767
768 let result = exporter.export_with_annotations(&screenshot, &annotations);
769 assert!(result.is_ok());
770 }
771 }
772
773 mod exporter_edge_cases {
774 use super::*;
775
776 #[test]
777 fn test_exporter_default() {
778 let exporter = PngExporter::default();
779 assert_eq!(exporter.compression(), CompressionLevel::Default);
780 }
781
782 #[test]
783 fn test_compression_none() {
784 let exporter = PngExporter::new().with_compression(CompressionLevel::None);
785 let screenshot = create_test_screenshot(50, 50, [128, 128, 128, 255]);
786
787 let result = exporter.export(&screenshot);
788 assert!(result.is_ok());
789 }
790
791 #[test]
792 fn test_metadata_with_timestamp() {
793 let meta = PngMetadata::new().with_timestamp(SystemTime::now());
794 assert!(meta.timestamp.is_some());
795 }
796
797 #[test]
798 fn test_annotation_at_image_boundary() {
799 let exporter = PngExporter::new();
800 let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
801
802 let annotations = vec![Annotation::rectangle(80, 80, 50, 50)];
804
805 let result = exporter.export_with_annotations(&screenshot, &annotations);
806 assert!(result.is_ok());
807 }
808
809 #[test]
810 fn test_multiple_annotation_types() {
811 let exporter = PngExporter::new();
812 let screenshot = create_test_screenshot(200, 200, [200, 200, 200, 255]);
813
814 let annotations = vec![
815 Annotation::rectangle(10, 10, 30, 30),
816 Annotation::highlight(50, 10, 30, 30),
817 Annotation {
818 x: 90,
819 y: 10,
820 width: 30,
821 height: 30,
822 color: [0, 255, 0, 255],
823 kind: AnnotationKind::FilledRectangle,
824 label: None,
825 },
826 Annotation {
827 x: 130,
828 y: 10,
829 width: 30,
830 height: 30,
831 color: [255, 0, 255, 255],
832 kind: AnnotationKind::Circle,
833 label: None,
834 },
835 ];
836
837 let result = exporter.export_with_annotations(&screenshot, &annotations);
838 assert!(result.is_ok());
839 }
840
841 #[test]
842 fn test_all_compression_to_png_conversion() {
843 let _ = CompressionLevel::None.to_png_compression();
845 let _ = CompressionLevel::Fast.to_png_compression();
846 let _ = CompressionLevel::Default.to_png_compression();
847 let _ = CompressionLevel::Best.to_png_compression();
848 }
849 }
850
851 mod property_tests {
852 use super::*;
853 use proptest::prelude::*;
854
855 proptest! {
856 #[test]
857 fn prop_export_produces_valid_png(
858 width in 1u32..100,
859 height in 1u32..100,
860 r in 0u8..=255,
861 g in 0u8..=255,
862 b in 0u8..=255
863 ) {
864 let exporter = PngExporter::new();
865 let screenshot = create_test_screenshot(width, height, [r, g, b, 255]);
866
867 let result = exporter.export(&screenshot);
868 prop_assert!(result.is_ok());
869
870 let png_data = result.unwrap();
871 prop_assert_eq!(&png_data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
873 }
874
875 #[test]
876 fn prop_annotation_bounds_respected(
877 x in 0u32..100,
878 y in 0u32..100,
879 w in 1u32..50,
880 h in 1u32..50
881 ) {
882 let ann = Annotation::rectangle(x, y, w, h);
883 prop_assert_eq!(ann.x, x);
884 prop_assert_eq!(ann.y, y);
885 prop_assert_eq!(ann.width, w);
886 prop_assert_eq!(ann.height, h);
887 }
888
889 #[test]
890 fn prop_blend_channel_in_range(
891 base in 0u8..=255,
892 overlay in 0u8..=255,
893 alpha in 0.0f32..=1.0
894 ) {
895 let result = blend_channel(base, overlay, alpha);
896 let expected_min = base.min(overlay);
899 let expected_max = base.max(overlay);
900 prop_assert!(result >= expected_min || alpha < 1.0);
901 prop_assert!(result <= expected_max || alpha > 0.0);
902 }
903 }
904 }
905}