Skip to main content

compress_pdf/verify/
render.rs

1//! Output verification, visual level (AGENTS.md, "Output verification").
2//!
3//! Every page of the input and of the output is rasterized with `hayro`
4//! at a fixed low resolution, converted to gray, and compared with SSIM
5//! (structural similarity: means, variances and covariance over blocks,
6//! which forgives a re-encoded photo's noise but not a missing image, a
7//! shifted glyph or a lost page). A page under the preset's floor is a
8//! warning by default; callers may make it a failure.
9
10use 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
19/// Rendering resolution. Low, so a full document renders in seconds and
20/// the comparison judges layout and content rather than pixel noise.
21pub const DPI: f32 = 72.0;
22
23/// A page whose shorter side would render below this many pixels is
24/// scaled up to it, so a stamp-sized page still yields enough blocks for
25/// the comparison to mean something.
26const MIN_SIDE: f32 = 128.0;
27
28/// Pixels a rendered page may have at most; a page with an enormous media
29/// box is scaled down to fit, since the comparison is about layout and
30/// content, not resolution, and both documents render at the same scale.
31const MAX_PIXELS: f32 = 4_000_000.0;
32
33/// SSIM a page must reach for a preset's output to count as faithful.
34/// Provisional: set from the corpus and probes, to be revisited against
35/// reference outputs.
36pub 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    /// Zero-based page index.
48    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
90/// Render both documents and score every page. Errors name what could
91/// not be rendered; a page count mismatch is scored as zero for the
92/// missing pages rather than an error, so it shows up as a failure.
93pub 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
114/// A page as 8-bit gray on white, rendered at [`DPI`] and averaged over
115/// 2x2 pixels: a re-encoded or downsampled image lands on a different
116/// pixel grid when rasterized, and the averaging keeps that from reading
117/// as a structural change while a missing or shifted element still does.
118fn 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        // Premultiplied over white: alpha is 1 after the background fill.
140        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
176/// Mean SSIM over 8x8 blocks with the usual constants; 1.0 for identical
177/// images, 0 when the sizes differ.
178fn 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}