tytanic_core/doc/
compare.rs

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