winprint 0.2.1

A crate for printing to a Windows printer device using Windows API
Documentation
//! Visual similarity comparison for regression tests.

use crate::common::loader;
use image::{imageops::FilterType, DynamicImage, Rgb, RgbImage};
use std::path::PathBuf;

pub const DEFAULT_MIN_SCORE: f64 = 0.90;

/// Letterbox `img` into a `(container_w, container_h)` canvas, preserving
/// its aspect ratio and padding the rest with white.
fn fit_and_pad(img: &RgbImage, container_w: u32, container_h: u32) -> RgbImage {
    let (w, h) = img.dimensions();
    let scale = (container_w as f64 / w as f64).min(container_h as f64 / h as f64);
    let new_w = ((w as f64 * scale).round() as u32).clamp(1, container_w);
    let new_h = ((h as f64 * scale).round() as u32).clamp(1, container_h);
    let resized = image::imageops::resize(img, new_w, new_h, FilterType::Lanczos3);
    if new_w == container_w && new_h == container_h {
        // Fast path: no letterboxing needed.
        return resized;
    }
    let mut canvas = RgbImage::from_pixel(container_w, container_h, Rgb([255, 255, 255]));
    let x = ((container_w - new_w) / 2) as i64;
    let y = ((container_h - new_h) / 2) as i64;
    image::imageops::overlay(&mut canvas, &resized, x, y);
    canvas
}

pub fn compare_single_page(actual: &DynamicImage, expected: &DynamicImage) -> f64 {
    let actual_rgb = actual.to_rgb8();
    let expected_rgb = expected.to_rgb8();
    let (w, h) = expected_rgb.dimensions();
    let resized_actual = fit_and_pad(&actual_rgb, w, h);
    let result = image_compare::rgb_hybrid_compare(&resized_actual, &expected_rgb)
        .expect("SSIM comparison failed");
    result.score
}

/// Expectation for a single visual regression test.
pub struct VisualExpectation {
    // Path of expected image, in TIFF format.
    pub expected_path: PathBuf,
    /// Minimum acceptable score per page.
    pub min_score: f64,
}

impl VisualExpectation {
    pub const fn new(expected_tiff_path: PathBuf) -> Self {
        Self {
            expected_path: expected_tiff_path,
            min_score: DEFAULT_MIN_SCORE,
        }
    }

    #[allow(dead_code)]
    pub const fn with_min_score(mut self, v: f64) -> Self {
        self.min_score = v;
        self
    }

    pub fn assert(&self, actual_pages: &[DynamicImage]) {
        let expected_pages =
            loader::load_pages_from_tiff(&self.expected_path).expect("load expected TIFF");

        assert_eq!(
            actual_pages.len(),
            expected_pages.len(),
            "page count mismatch: actual={}, expected={}",
            actual_pages.len(),
            expected_pages.len()
        );

        let mut failures = Vec::new();
        for (i, (actual_image, expected_image)) in
            actual_pages.iter().zip(expected_pages.iter()).enumerate()
        {
            let score = compare_single_page(actual_image, expected_image);
            if score < self.min_score {
                failures.push(format!("page {}, score: {:.2}", i + 1, score));
            }
        }

        assert!(
            failures.is_empty(),
            "visual regression failures:\n  - {}",
            failures.join("\n  - ")
        );
    }
}