tytanic_core/doc/
compare.rs

1//! Comparison of rendered pages.
2//!
3//! This currently only provies a single primitive comparison algorithm,
4//! [`Strategy::Simple`].
5
6use std::fmt::{Debug, Display};
7
8use thiserror::Error;
9use tiny_skia::Pixmap;
10use tytanic_utils::fmt::Term;
11
12/// A struct representing page size in pixels.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct Size {
15    /// The width of the page.
16    pub width: u32,
17
18    /// The height of the page.
19    pub height: u32,
20}
21
22impl Display for Size {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        write!(f, "{}x{}", self.width, self.height)
25    }
26}
27
28/// The strategy to use for visual comparison.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub enum Strategy {
31    /// Use a simple pixel channel difference comparison, setting both fields
32    /// to `0` makes an exact comparison.
33    Simple {
34        /// The maximum allowed difference between a channel of two pixels
35        /// before the pixel is considered different. A single channel mismatch
36        /// is enough to mark a pixel as a deviation.
37        max_delta: u8,
38
39        /// The maximum allowed amount of pixels that can differ per page in
40        /// accordance to `max_delta` before two pages are considered different.
41        max_deviation: usize,
42    },
43}
44
45impl Default for Strategy {
46    fn default() -> Self {
47        Self::Simple {
48            max_delta: 0,
49            max_deviation: 0,
50        }
51    }
52}
53
54/// Compares two pages individually using the given strategy.
55pub fn page(output: &Pixmap, reference: &Pixmap, strategy: Strategy) -> Result<(), PageError> {
56    match strategy {
57        Strategy::Simple {
58            max_delta,
59            max_deviation,
60        } => page_simple(output, reference, max_delta, max_deviation),
61    }
62}
63
64/// Compares two pages individually using [`Strategy::Simple`].
65fn page_simple(
66    output: &Pixmap,
67    reference: &Pixmap,
68    max_delta: u8,
69    max_deviation: usize,
70) -> Result<(), PageError> {
71    if output.width() != reference.width() || output.height() != reference.height() {
72        return Err(PageError::Dimensions {
73            output: Size {
74                width: output.width(),
75                height: output.height(),
76            },
77            reference: Size {
78                width: reference.width(),
79                height: reference.height(),
80            },
81        });
82    }
83
84    let deviations = Iterator::zip(output.pixels().iter(), reference.pixels().iter())
85        .filter(|(a, b)| {
86            u8::abs_diff(a.red(), b.red()) > max_delta
87                || u8::abs_diff(a.green(), b.green()) > max_delta
88                || u8::abs_diff(a.blue(), b.blue()) > max_delta
89                || u8::abs_diff(a.alpha(), b.alpha()) > max_delta
90        })
91        .count();
92
93    if deviations > max_deviation {
94        return Err(PageError::SimpleDeviations { deviations });
95    }
96
97    Ok(())
98}
99
100/// An error describing why a document comparison failed.
101#[derive(Debug, Clone, Error)]
102pub struct Error {
103    /// The output page count.
104    pub output: usize,
105
106    /// The reference page count.
107    pub reference: usize,
108
109    /// The page failures if there are any with their indices.
110    pub pages: Vec<(usize, PageError)>,
111}
112
113impl Display for Error {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        if self.output != self.reference {
116            write!(
117                f,
118                "page count differed (out {} != ref {})",
119                self.output, self.reference,
120            )?;
121        }
122
123        if self.output != self.reference && self.pages.is_empty() {
124            write!(f, " and ")?;
125        }
126
127        if self.pages.is_empty() {
128            write!(
129                f,
130                "{} {} differed at indices: {:?}",
131                self.pages.len(),
132                Term::simple("page").with(self.pages.len()),
133                self.pages.iter().map(|(n, _)| n).collect::<Vec<_>>()
134            )?;
135        }
136
137        Ok(())
138    }
139}
140
141/// An error describing why a page comparison failed.
142#[derive(Debug, Clone, Error)]
143pub enum PageError {
144    /// The dimensions of the pages did not match.
145    #[error("dimensions differed: out {output} != ref {reference}")]
146    Dimensions {
147        /// The size of the output page.
148        output: Size,
149
150        /// The size of the reference page.
151        reference: Size,
152    },
153
154    /// The pages differed according to [`Strategy::Simple`].
155    #[error(
156        "content differed in at least {} {}",
157        deviations,
158        Term::simple("pixel").with(*deviations)
159    )]
160    SimpleDeviations {
161        /// The amount of visual deviations, i.e. the amount of pixels which did
162        /// not match according to the visual strategy.
163        deviations: usize,
164    },
165}
166
167#[cfg(test)]
168mod tests {
169    use tiny_skia::PremultipliedColorU8;
170
171    use super::*;
172
173    fn images() -> [Pixmap; 2] {
174        let a = Pixmap::new(10, 1).unwrap();
175        let mut b = Pixmap::new(10, 1).unwrap();
176
177        let red = PremultipliedColorU8::from_rgba(128, 0, 0, 128).unwrap();
178        b.pixels_mut()[0] = red;
179        b.pixels_mut()[1] = red;
180        b.pixels_mut()[2] = red;
181        b.pixels_mut()[3] = red;
182
183        [a, b]
184    }
185
186    #[test]
187    fn test_page_simple_below_max_delta() {
188        let [a, b] = images();
189        assert!(page(
190            &a,
191            &b,
192            Strategy::Simple {
193                max_delta: 128,
194                max_deviation: 0,
195            },
196        )
197        .is_ok())
198    }
199
200    #[test]
201    fn test_page_simple_below_max_devitation() {
202        let [a, b] = images();
203        assert!(page(
204            &a,
205            &b,
206            Strategy::Simple {
207                max_delta: 0,
208                max_deviation: 5,
209            },
210        )
211        .is_ok());
212    }
213
214    #[test]
215    fn test_page_simple_above_max_devitation() {
216        let [a, b] = images();
217        assert!(matches!(
218            page(
219                &a,
220                &b,
221                Strategy::Simple {
222                    max_delta: 0,
223                    max_deviation: 0,
224                },
225            ),
226            Err(PageError::SimpleDeviations { deviations: 4 })
227        ))
228    }
229}