Skip to main content

lightweight_pdf_testing/
lib.rs

1//! Pixel-diff snapshot testing for rendered PDFs (issue #21): render →
2//! rasterize (`pdftoppm`, part of `poppler-utils` — already a required
3//! tool for this workspace's own text-extraction tests, so this adds no
4//! new system dependency) → compare each page against a checked-in
5//! reference PNG, with a small per-pixel tolerance for renderer noise.
6//!
7//! Deliberately independent of `lightweight-pdf-test-support` (that
8//! crate is this workspace's own internal dev-dependency, never
9//! published) — this crate stands on its own, usable to pin *any* PDF
10//! (not just ones built with `lightweight-pdf`) against visual
11//! regressions.
12//!
13//! Reference images are low-DPI grayscale PNG (`DEFAULT_DPI`) on purpose
14//! — this is a regression trip-wire, not a print-quality visual proof,
15//! and keeping them small keeps the repository's history small.
16//!
17//! ```no_run
18//! # fn render() -> Vec<u8> { vec![] }
19//! let dir = std::path::Path::new("test-fixtures/snapshots");
20//! lightweight_pdf_testing::assert_snapshot(dir, "invoice", &render());
21//! ```
22//!
23//! Set `UPDATE_SNAPSHOTS=1` to (re)write the reference images instead of
24//! comparing against them.
25
26use std::path::{Path, PathBuf};
27use std::process::Command;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30/// Low on purpose (see module doc) — this is a regression trip-wire, not
31/// a print-quality visual proof.
32pub const DEFAULT_DPI: u32 = 72;
33/// Maximum per-pixel grayscale value difference (0-255) still counted as
34/// "the same" — absorbs the small amount of renderer anti-aliasing noise
35/// between otherwise-identical runs.
36pub const DEFAULT_TOLERANCE: u8 = 12;
37
38#[derive(Debug)]
39pub enum SnapshotError {
40    Rasterize(String),
41    Decode(String),
42    /// No reference image exists yet for this page — not necessarily a
43    /// bug, just needs a `UPDATE_SNAPSHOTS=1` run once.
44    NoReference(PathBuf),
45    /// The rendered document has a different number of pages than the
46    /// checked-in reference set.
47    PageCountMismatch {
48        expected: usize,
49        actual: usize,
50    },
51    Mismatch {
52        page: usize,
53        reference_path: PathBuf,
54        diff_path: PathBuf,
55        differing_pixels: usize,
56        total_pixels: usize,
57    },
58}
59
60impl std::fmt::Display for SnapshotError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            SnapshotError::Rasterize(msg) => write!(f, "rasterizing the PDF failed: {msg}"),
64            SnapshotError::Decode(msg) => write!(f, "decoding a snapshot PNG failed: {msg}"),
65            SnapshotError::NoReference(path) => {
66                write!(
67                    f,
68                    "no reference snapshot at {} — run once with UPDATE_SNAPSHOTS=1 to create it",
69                    path.display()
70                )
71            }
72            SnapshotError::PageCountMismatch { expected, actual } => {
73                write!(f, "expected {expected} page(s) (reference), rendered {actual}")
74            }
75            SnapshotError::Mismatch {
76                page,
77                reference_path,
78                diff_path,
79                differing_pixels,
80                total_pixels,
81            } => {
82                write!(
83                    f,
84                    "page {page} doesn't match {} — {differing_pixels}/{total_pixels} pixels differ beyond tolerance; diff image written to {}",
85                    reference_path.display(),
86                    diff_path.display()
87                )
88            }
89        }
90    }
91}
92
93impl std::error::Error for SnapshotError {}
94
95/// `check_snapshot` with `DEFAULT_DPI`/`DEFAULT_TOLERANCE`, panicking
96/// with a descriptive message on any failure — the usual entry point
97/// from a `#[test]` function.
98pub fn assert_snapshot(snapshot_dir: &Path, name: &str, pdf_bytes: &[u8]) {
99    if let Err(err) = check_snapshot(snapshot_dir, name, pdf_bytes, DEFAULT_DPI, DEFAULT_TOLERANCE) {
100        panic!("{err}");
101    }
102}
103
104/// Rasterizes `pdf_bytes` at `dpi` and compares every page against
105/// `<snapshot_dir>/<name>-<page>.png`. With `UPDATE_SNAPSHOTS` set (any
106/// value), (re)writes the reference images instead of comparing.
107pub fn check_snapshot(snapshot_dir: &Path, name: &str, pdf_bytes: &[u8], dpi: u32, tolerance: u8) -> Result<(), SnapshotError> {
108    let rendered_pages = rasterize(pdf_bytes, dpi)?;
109
110    std::fs::create_dir_all(snapshot_dir).map_err(|e| SnapshotError::Rasterize(format!("create {}: {e}", snapshot_dir.display())))?;
111
112    if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
113        // Remove any reference pages beyond the newly-rendered count, so
114        // a page-count shrink doesn't leave a stale reference behind.
115        let mut stale = rendered_pages.len() + 1;
116        while reference_path(snapshot_dir, name, stale).exists() {
117            // Best-effort: `.exists()` above already confirmed there's
118            // something to remove; a failure here just leaves the stale
119            // file for next time, it doesn't affect this run's snapshots.
120            std::fs::remove_file(reference_path(snapshot_dir, name, stale)).ok();
121            stale += 1;
122        }
123        for (i, page_png) in rendered_pages.iter().enumerate() {
124            let path = reference_path(snapshot_dir, name, i + 1);
125            let gray = decode_to_gray(page_png)?;
126            write_gray_png(&path, gray.width, gray.height, &gray.pixels)?;
127        }
128        eprintln!("updated {} snapshot page(s) for {name:?}", rendered_pages.len());
129        return Ok(());
130    }
131
132    if reference_path(snapshot_dir, name, rendered_pages.len() + 1).exists() {
133        let mut expected = rendered_pages.len() + 1;
134        while reference_path(snapshot_dir, name, expected + 1).exists() {
135            expected += 1;
136        }
137        return Err(SnapshotError::PageCountMismatch {
138            expected,
139            actual: rendered_pages.len(),
140        });
141    }
142
143    for (i, rendered_png) in rendered_pages.iter().enumerate() {
144        let page = i + 1;
145        let reference_path = reference_path(snapshot_dir, name, page);
146        if !reference_path.exists() {
147            return Err(SnapshotError::NoReference(reference_path));
148        }
149        let reference_png =
150            std::fs::read(&reference_path).map_err(|e| SnapshotError::Decode(format!("read {}: {e}", reference_path.display())))?;
151        compare_page(page, &reference_path, &reference_png, rendered_png, tolerance)?;
152    }
153
154    Ok(())
155}
156
157fn reference_path(snapshot_dir: &Path, name: &str, page: usize) -> PathBuf {
158    snapshot_dir.join(format!("{name}-{page}.png"))
159}
160
161static UNIQUE: AtomicU64 = AtomicU64::new(0);
162
163/// Rasterizes `pdf_bytes` at `dpi` via `pdftoppm -gray -png`, returning
164/// each page's raw PNG bytes in order — `pdftoppm` always numbers pages
165/// from 1 (`<prefix>-1.png`, `<prefix>-2.png`, ...), even for a
166/// single-page document.
167fn rasterize(pdf_bytes: &[u8], dpi: u32) -> Result<Vec<Vec<u8>>, SnapshotError> {
168    let n = UNIQUE.fetch_add(1, Ordering::Relaxed);
169    let dir = std::env::temp_dir().join(format!("lightweight-pdf-testing-{}-{n}", std::process::id()));
170    std::fs::create_dir_all(&dir).map_err(|e| SnapshotError::Rasterize(format!("create temp dir: {e}")))?;
171    let pdf_path = dir.join("input.pdf");
172    std::fs::write(&pdf_path, pdf_bytes).map_err(|e| SnapshotError::Rasterize(format!("write temp pdf: {e}")))?;
173    let prefix = dir.join("page");
174
175    let output = Command::new("pdftoppm")
176        .arg("-gray")
177        .arg("-png")
178        .arg("-r")
179        .arg(dpi.to_string())
180        .arg(&pdf_path)
181        .arg(&prefix)
182        .output()
183        .map_err(|e| SnapshotError::Rasterize(format!("run pdftoppm: {e}")))?;
184    if !output.status.success() {
185        // Best-effort cleanup of the temp dir before returning the real
186        // error below — a failure to remove it doesn't change the outcome
187        // of this rasterize call.
188        std::fs::remove_dir_all(&dir).ok();
189        return Err(SnapshotError::Rasterize(format!(
190            "pdftoppm failed:\n{}{}",
191            String::from_utf8_lossy(&output.stdout),
192            String::from_utf8_lossy(&output.stderr)
193        )));
194    }
195
196    let mut pages = Vec::new();
197    let mut page = 1usize;
198    loop {
199        let path = dir.join(format!("page-{page}.png"));
200        let Ok(bytes) = std::fs::read(&path) else { break };
201        pages.push(bytes);
202        page += 1;
203    }
204    // Best-effort cleanup — the PNG bytes are already read into `pages`
205    // above, so a failure to remove the temp dir doesn't affect the result.
206    std::fs::remove_dir_all(&dir).ok();
207    Ok(pages)
208}
209
210struct GrayImage {
211    width: u32,
212    height: u32,
213    pixels: Vec<u8>,
214}
215
216/// Decodes any 8-bit PNG `pdftoppm -gray` might actually emit and
217/// normalizes it to one grayscale byte per pixel. Despite the `-gray`
218/// flag, poppler has been observed to still emit a Truecolor (RGB) PNG
219/// with R == G == B rather than a literal single-channel grayscale one —
220/// every channel layout it could plausibly produce is handled here so
221/// callers never have to care, and everything this crate itself *writes*
222/// (`write_gray_png`) is always the literal single-channel format
223/// regardless of what pdftoppm handed us, keeping reference files small.
224fn decode_to_gray(bytes: &[u8]) -> Result<GrayImage, SnapshotError> {
225    let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
226    let mut reader = decoder.read_info().map_err(|e| SnapshotError::Decode(e.to_string()))?;
227    let mut buf = vec![
228        0u8;
229        reader
230            .output_buffer_size()
231            .ok_or_else(|| SnapshotError::Decode("empty image".into()))?
232    ];
233    let info = reader.next_frame(&mut buf).map_err(|e| SnapshotError::Decode(e.to_string()))?;
234    buf.truncate(info.buffer_size());
235    if info.bit_depth != png::BitDepth::Eight {
236        return Err(SnapshotError::Decode(format!("expected an 8-bit PNG, got {:?}", info.bit_depth)));
237    }
238    let pixels = match info.color_type {
239        png::ColorType::Grayscale => buf,
240        png::ColorType::GrayscaleAlpha => buf.as_chunks::<2>().0.iter().map(|px| px[0]).collect(),
241        png::ColorType::Rgb => buf.as_chunks::<3>().0.iter().map(|px| px[0]).collect(),
242        png::ColorType::Rgba => buf.as_chunks::<4>().0.iter().map(|px| px[0]).collect(),
243        other => return Err(SnapshotError::Decode(format!("unsupported PNG color type {other:?}"))),
244    };
245    Ok(GrayImage {
246        width: info.width,
247        height: info.height,
248        pixels,
249    })
250}
251
252fn compare_page(page: usize, reference_path: &Path, reference_png: &[u8], rendered_png: &[u8], tolerance: u8) -> Result<(), SnapshotError> {
253    let reference = decode_to_gray(reference_png)?;
254    let rendered = decode_to_gray(rendered_png)?;
255
256    if reference.width != rendered.width || reference.height != rendered.height {
257        return Err(SnapshotError::Decode(format!(
258            "page {page}: reference is {}x{}, rendered is {}x{} — DPI mismatch?",
259            reference.width, reference.height, rendered.width, rendered.height
260        )));
261    }
262
263    let mut differing = 0usize;
264    let mut diff_pixels = Vec::with_capacity(reference.pixels.len());
265    for (&r, &v) in reference.pixels.iter().zip(&rendered.pixels) {
266        let delta = r.abs_diff(v);
267        if delta > tolerance {
268            differing += 1;
269            diff_pixels.push(255u8); // highlight in the diff image
270        } else {
271            diff_pixels.push(0u8);
272        }
273    }
274
275    if differing == 0 {
276        return Ok(());
277    }
278
279    let diff_path = reference_path.with_extension("diff.png");
280    write_gray_png(&diff_path, reference.width, reference.height, &diff_pixels)?;
281    Err(SnapshotError::Mismatch {
282        page,
283        reference_path: reference_path.to_path_buf(),
284        diff_path,
285        differing_pixels: differing,
286        total_pixels: reference.pixels.len(),
287    })
288}
289
290fn write_gray_png(path: &Path, width: u32, height: u32, pixels: &[u8]) -> Result<(), SnapshotError> {
291    let file = std::fs::File::create(path).map_err(|e| SnapshotError::Rasterize(format!("create {}: {e}", path.display())))?;
292    let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
293    encoder.set_color(png::ColorType::Grayscale);
294    encoder.set_depth(png::BitDepth::Eight);
295    let mut writer = encoder.write_header().map_err(|e| SnapshotError::Rasterize(e.to_string()))?;
296    writer
297        .write_image_data(pixels)
298        .map_err(|e| SnapshotError::Rasterize(e.to_string()))?;
299    Ok(())
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn tiny_pdf() -> Vec<u8> {
307        // Minimal one-page, blank PDF — enough for `pdftoppm` to rasterize
308        // without needing the rest of this workspace's writer.
309        b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF"
310            .to_vec()
311    }
312
313    // All three scenarios live in one `#[test]` function rather than
314    // three: `check_snapshot`'s only way to switch into "write the
315    // reference" mode is the process-global `UPDATE_SNAPSHOTS` env var,
316    // and `cargo test` runs `#[test]` functions on separate threads of
317    // the *same* process by default — separate tests toggling a shared
318    // env var would race each other.
319    #[test]
320    fn snapshot_lifecycle() {
321        let dir = std::env::temp_dir().join(format!("lightweight-pdf-testing-test-{}", std::process::id()));
322        // Best-effort: clear a leftover dir from a previous failed run;
323        // `create_dir_all` inside `check_snapshot` below is what actually
324        // needs to succeed.
325        std::fs::remove_dir_all(&dir).ok();
326        let pdf = tiny_pdf();
327
328        // 1. No reference yet.
329        let err = check_snapshot(&dir, "blank", &pdf, DEFAULT_DPI, DEFAULT_TOLERANCE).unwrap_err();
330        assert!(matches!(err, SnapshotError::NoReference(_)), "got: {err}");
331
332        // 2. UPDATE_SNAPSHOTS=1 writes it, then a normal comparison
333        // against the identical PDF succeeds.
334        std::env::set_var("UPDATE_SNAPSHOTS", "1");
335        check_snapshot(&dir, "blank", &pdf, DEFAULT_DPI, DEFAULT_TOLERANCE).expect("update should succeed");
336        std::env::remove_var("UPDATE_SNAPSHOTS");
337        check_snapshot(&dir, "blank", &pdf, DEFAULT_DPI, DEFAULT_TOLERANCE)
338            .expect("comparing against the just-written reference should succeed");
339
340        // 3. A visibly different page is reported as a mismatch, with a
341        // diff image written next to the reference.
342        let different_pdf = b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]/Contents 4 0 R/Resources<<>>>>endobj\n4 0 obj<</Length 40>>stream\n0 0 0 rg 0 0 200 200 re f\nendstream endobj\ntrailer<</Root 1 0 R>>\n%%EOF".to_vec();
343        let err = check_snapshot(&dir, "blank", &different_pdf, DEFAULT_DPI, DEFAULT_TOLERANCE).unwrap_err();
344        let SnapshotError::Mismatch { diff_path, .. } = &err else {
345            panic!("got: {err}");
346        };
347        assert!(diff_path.exists(), "expected a diff image at {}", diff_path.display());
348
349        // Best-effort cleanup — this test's assertions already ran.
350        std::fs::remove_dir_all(&dir).ok();
351    }
352}