1use std::path::{Path, PathBuf};
27use std::process::Command;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30pub const DEFAULT_DPI: u32 = 72;
33pub const DEFAULT_TOLERANCE: u8 = 12;
37
38#[derive(Debug)]
39pub enum SnapshotError {
40 Rasterize(String),
41 Decode(String),
42 NoReference(PathBuf),
45 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
95pub 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
104pub 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 let mut stale = rendered_pages.len() + 1;
116 while reference_path(snapshot_dir, name, stale).exists() {
117 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
163fn 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 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 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
216fn 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); } 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 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 #[test]
320 fn snapshot_lifecycle() {
321 let dir = std::env::temp_dir().join(format!("lightweight-pdf-testing-test-{}", std::process::id()));
322 std::fs::remove_dir_all(&dir).ok();
326 let pdf = tiny_pdf();
327
328 let err = check_snapshot(&dir, "blank", &pdf, DEFAULT_DPI, DEFAULT_TOLERANCE).unwrap_err();
330 assert!(matches!(err, SnapshotError::NoReference(_)), "got: {err}");
331
332 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 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 std::fs::remove_dir_all(&dir).ok();
351 }
352}