1use std::fmt;
11
12use hayro::hayro_syntax::Pdf;
13use hayro::hayro_syntax::page::Page;
14use hayro::vello_cpu::color::palette::css::WHITE;
15use hayro::{RenderCache, RenderSettings, hayro_interpret::InterpreterSettings};
16
17use crate::config::Preset;
18
19pub const DPI: f32 = 72.0;
22
23const MIN_SIDE: f32 = 128.0;
27
28const MAX_PIXELS: f32 = 4_000_000.0;
32
33pub fn floor(preset: Preset) -> f32 {
37 match preset {
38 Preset::Less => 0.95,
39 Preset::Standard => 0.93,
40 Preset::More => 0.90,
41 }
42}
43
44#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct PageScore {
47 pub page: usize,
49 pub ssim: f32,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53#[non_exhaustive]
54pub struct Comparison {
55 pub pages: Vec<PageScore>,
56 pub floor: f32,
57}
58
59impl Comparison {
60 pub fn below_floor(&self) -> Vec<&PageScore> {
61 self.pages.iter().filter(|p| p.ssim < self.floor).collect()
62 }
63
64 pub fn min(&self) -> Option<f32> {
65 self.pages.iter().map(|p| p.ssim).reduce(f32::min)
66 }
67}
68
69impl fmt::Display for Comparison {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 write!(
72 f,
73 "render: {} pages compared at {DPI:.0} dpi, min SSIM {:.4} (floor {:.2})",
74 self.pages.len(),
75 self.min().unwrap_or(1.0),
76 self.floor
77 )?;
78 for p in self.below_floor() {
79 write!(
80 f,
81 "\n - page {}: SSIM {:.4} below floor",
82 p.page + 1,
83 p.ssim
84 )?;
85 }
86 Ok(())
87 }
88}
89
90pub fn compare(input: &[u8], output: &[u8], preset: Preset) -> Result<Comparison, String> {
94 let before = Pdf::new(input.to_vec())
95 .map_err(|e| format!("input does not load for rendering: {e:?}"))?;
96 let after = Pdf::new(output.to_vec())
97 .map_err(|e| format!("output does not load for rendering: {e:?}"))?;
98 let (pages_before, pages_after) = (before.pages(), after.pages());
99 let count = pages_before.len().max(pages_after.len());
100 let mut pages = Vec::with_capacity(count);
101 for i in 0..count {
102 let ssim = match (pages_before.get(i), pages_after.get(i)) {
103 (Some(a), Some(b)) => ssim_gray(&render_gray(a), &render_gray(b)),
104 _ => 0.0,
105 };
106 pages.push(PageScore { page: i, ssim });
107 }
108 Ok(Comparison {
109 pages,
110 floor: floor(preset),
111 })
112}
113
114fn render_gray(page: &Page<'_>) -> Gray {
119 let (w, h) = page.render_dimensions();
120 let scale = (DPI / 72.0)
121 .max(MIN_SIDE / w.min(h).max(1.0))
122 .min((MAX_PIXELS / (w * h).max(1.0)).sqrt());
123 let settings = RenderSettings {
124 x_scale: scale,
125 y_scale: scale,
126 bg_color: WHITE,
127 ..RenderSettings::default()
128 };
129 let pixmap = hayro::render(
130 page,
131 &RenderCache::new(),
132 &InterpreterSettings::default(),
133 &settings,
134 );
135 let (width, height) = (usize::from(pixmap.width()), usize::from(pixmap.height()));
136 let data = pixmap.data_as_u8_slice();
137 let mut gray = Vec::with_capacity(width * height);
138 for px in data.as_chunks::<4>().0 {
139 gray.push(
141 ((u32::from(px[0]) * 299 + u32::from(px[1]) * 587 + u32::from(px[2]) * 114) / 1000)
142 as u8,
143 );
144 }
145 halve(&Gray {
146 width,
147 height,
148 data: gray,
149 })
150}
151
152fn halve(g: &Gray) -> Gray {
153 let (w, h) = (g.width / 2, g.height / 2);
154 let mut data = Vec::with_capacity(w * h);
155 for y in 0..h {
156 for x in 0..w {
157 let at = |dx: usize, dy: usize| u32::from(g.data[(2 * y + dy) * g.width + 2 * x + dx]);
158 data.push(((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4) as u8);
159 }
160 }
161 Gray {
162 width: w,
163 height: h,
164 data,
165 }
166}
167
168struct Gray {
169 width: usize,
170 height: usize,
171 data: Vec<u8>,
172}
173
174const BLOCK: usize = 8;
175
176fn ssim_gray(a: &Gray, b: &Gray) -> f32 {
179 if a.width != b.width || a.height != b.height || a.width == 0 || a.height == 0 {
180 return 0.0;
181 }
182 let (c1, c2) = ((0.01f64 * 255.0).powi(2), (0.03f64 * 255.0).powi(2));
183 let (mut sum, mut blocks) = (0.0f64, 0usize);
184 for by in (0..a.height).step_by(BLOCK) {
185 for bx in (0..a.width).step_by(BLOCK) {
186 let (mut ma, mut mb, mut va, mut vb, mut cov, mut n) =
187 (0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0);
188 for y in by..(by + BLOCK).min(a.height) {
189 for x in bx..(bx + BLOCK).min(a.width) {
190 let (pa, pb) = (
191 f64::from(a.data[y * a.width + x]),
192 f64::from(b.data[y * b.width + x]),
193 );
194 ma += pa;
195 mb += pb;
196 va += pa * pa;
197 vb += pb * pb;
198 cov += pa * pb;
199 n += 1.0;
200 }
201 }
202 ma /= n;
203 mb /= n;
204 va = va / n - ma * ma;
205 vb = vb / n - mb * mb;
206 cov = cov / n - ma * mb;
207 sum += ((2.0 * ma * mb + c1) * (2.0 * cov + c2))
208 / ((ma * ma + mb * mb + c1) * (va + vb + c2));
209 blocks += 1;
210 }
211 }
212 (sum / blocks as f64) as f32
213}
214
215#[cfg(test)]
216mod tests {
217 use lopdf::{Document, Object, Stream, dictionary};
218
219 use super::*;
220
221 fn page_with(content: &[u8]) -> Vec<u8> {
222 let mut doc = Document::with_version("1.5");
223 let contents = doc.add_object(Stream::new(dictionary! {}, content.to_vec()));
224 let pages_id = doc.new_object_id();
225 let page = doc.add_object(dictionary! {
226 "Type" => "Page", "Parent" => pages_id, "Contents" => contents,
227 "MediaBox" => vec![0.into(), 0.into(), 200.into(), 200.into()],
228 });
229 doc.objects.insert(
230 pages_id,
231 Object::Dictionary(
232 dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1 },
233 ),
234 );
235 let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
236 doc.trailer.set("Root", catalog);
237 let mut out = Vec::new();
238 doc.save_to(&mut out).unwrap();
239 out
240 }
241
242 #[test]
243 fn identical_pages_score_one_and_changed_pages_less() {
244 let a = page_with(b"0 g 20 20 100 100 re f");
245 let same = compare(&a, &a, Preset::Standard).unwrap();
246 assert_eq!(same.pages.len(), 1);
247 assert!((same.pages[0].ssim - 1.0).abs() < 1e-6, "{same}");
248 assert!(same.below_floor().is_empty());
249 let b = page_with(b"0 g 60 60 100 100 re f");
250 let moved = compare(&a, &b, Preset::Standard).unwrap();
251 assert!(moved.pages[0].ssim < 0.95, "{moved}");
252 assert_eq!(moved.below_floor().len(), 1);
253 let empty = page_with(b"");
254 let gone = compare(&a, &empty, Preset::More).unwrap();
255 assert!(gone.pages[0].ssim < 0.9, "{gone}");
256 }
257
258 #[test]
259 fn ssim_handles_size_mismatch_and_flat_images() {
260 let flat = Gray {
261 width: 16,
262 height: 16,
263 data: vec![200; 256],
264 };
265 assert!((ssim_gray(&flat, &flat) - 1.0).abs() < 1e-6);
266 let other = Gray {
267 width: 8,
268 height: 8,
269 data: vec![0; 64],
270 };
271 assert_eq!(ssim_gray(&flat, &other), 0.0);
272 assert!(compare(b"not a pdf", b"not a pdf", Preset::Less).is_err());
273 }
274}