Skip to main content

compress_pdf/
verify.rs

1//! Output verification, structural level (AGENTS.md, "Output verification").
2//!
3//! Re-reads the serialized output with `hayro-syntax`, a parser that shares
4//! no code with lopdf, and checks that the file is usable: it loads, it has
5//! the expected page count, every page's content stream decodes, and every
6//! stream (images included) decodes with its declared filters. A problem
7//! here is a bug in the pipeline, never a warning: callers must not write
8//! the file.
9//!
10//! The visual level (render both documents and compare pages with SSIM)
11//! lives in [`render`].
12
13pub mod render;
14
15use std::fmt;
16
17use hayro_syntax::Pdf;
18use hayro_syntax::object::stream::ImageDecodeParams;
19use hayro_syntax::object::{Array, Dict, Name};
20
21/// Problems are capped so a badly broken file does not flood the report.
22const MAX_PROBLEMS: usize = 50;
23
24#[derive(Debug, Default)]
25#[non_exhaustive]
26pub struct Verification {
27    pub pages: usize,
28    pub objects: usize,
29    pub streams_checked: usize,
30    pub problems: Vec<String>,
31}
32
33impl Verification {
34    pub fn is_ok(&self) -> bool {
35        self.problems.is_empty()
36    }
37
38    fn problem(&mut self, msg: String) {
39        if self.problems.len() < MAX_PROBLEMS {
40            self.problems.push(msg);
41        }
42    }
43}
44
45impl fmt::Display for Verification {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(
48            f,
49            "verify: {} pages, {} objects, {} streams decoded, {} problems",
50            self.pages,
51            self.objects,
52            self.streams_checked,
53            self.problems.len()
54        )?;
55        for p in &self.problems {
56            write!(f, "\n  - {p}")?;
57        }
58        Ok(())
59    }
60}
61
62/// Problem categories, for comparing an output against its input. Object
63/// numbers change between the two, so comparison is by category count.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
65#[non_exhaustive]
66pub enum Category {
67    Parse,
68    PageCount,
69    ContentStream,
70    Stream,
71    Image,
72}
73
74impl Verification {
75    /// Problems in `self` that go beyond what `baseline` (normally the
76    /// verification of the input) already had, per category:
77    /// `(category, count here, count in baseline)`.
78    pub fn regressions_from(&self, baseline: &Verification) -> Vec<(Category, usize, usize)> {
79        let theirs = baseline.category_counts();
80        self.category_counts()
81            .into_iter()
82            .filter_map(|(cat, n)| {
83                let base = theirs
84                    .iter()
85                    .find(|(c, _)| *c == cat)
86                    .map_or(0, |(_, b)| *b);
87                (n > base).then_some((cat, n, base))
88            })
89            .collect()
90    }
91
92    fn category_counts(&self) -> Vec<(Category, usize)> {
93        let mut counts: Vec<(Category, usize)> = Vec::new();
94        for p in &self.problems {
95            let cat = categorize(p);
96            match counts.iter_mut().find(|(c, _)| *c == cat) {
97                Some(entry) => entry.1 += 1,
98                None => counts.push((cat, 1)),
99            }
100        }
101        counts
102    }
103}
104
105fn categorize(problem: &str) -> Category {
106    if problem.contains("does not parse") {
107        Category::Parse
108    } else if problem.contains("page count") {
109        Category::PageCount
110    } else if problem.contains("content stream") {
111        Category::ContentStream
112    } else if problem.contains("(ImageDecode)") {
113        Category::Image
114    } else {
115        Category::Stream
116    }
117}
118
119/// Check `output` as an independent reader would see it. `expected_pages`
120/// is the input's page count.
121pub fn verify(output: &[u8], expected_pages: usize) -> Verification {
122    let mut v = Verification::default();
123    let pdf = match Pdf::new(output.to_vec()) {
124        Ok(pdf) => pdf,
125        Err(e) => {
126            v.problem(format!("output does not parse: {e:?}"));
127            return v;
128        }
129    };
130    check_pages(&pdf, expected_pages, &mut v);
131    check_streams(&pdf, &mut v);
132    v
133}
134
135fn check_pages(pdf: &Pdf, expected: usize, v: &mut Verification) {
136    let pages = pdf.pages();
137    v.pages = pages.len();
138    if pages.len() != expected {
139        v.problem(format!(
140            "page count is {} but the input had {expected}",
141            pages.len()
142        ));
143    }
144    for (i, page) in pages.iter().enumerate() {
145        if page.raw().contains_key(b"Contents") && page.page_stream().is_none() {
146            v.problem(format!("page {}: content stream does not decode", i + 1));
147        }
148    }
149}
150
151fn check_streams(pdf: &Pdf, v: &mut Verification) {
152    for object in pdf.objects() {
153        v.objects += 1;
154        let Some(stream) = object.into_stream() else {
155            continue;
156        };
157        let dict = stream.dict();
158        if name_is(dict, b"Type", "XRef") {
159            continue;
160        }
161        let result = if name_is(dict, b"Subtype", "Image") {
162            stream.decoded_image(&image_params(dict)).map(|_| ())
163        } else {
164            stream.decoded().map(|_| ())
165        };
166        v.streams_checked += 1;
167        if let Err(e) = result {
168            v.problem(format!(
169                "object {:?}: stream does not decode ({e:?})",
170                stream.obj_id()
171            ));
172        }
173    }
174}
175
176fn name_is(dict: &Dict<'_>, key: &[u8], value: &str) -> bool {
177    dict.get::<Name<'_>>(key)
178        .is_some_and(|n| n.as_str() == value)
179}
180
181/// What the image decoders need to know up front; everything else they read
182/// from the codestream.
183fn image_params(dict: &Dict<'_>) -> ImageDecodeParams {
184    let cs = color_space_family(dict);
185    let num_components = match cs.as_deref() {
186        Some("DeviceGray" | "CalGray") => Some(1),
187        Some("DeviceRGB" | "CalRGB" | "Lab") => Some(3),
188        Some("DeviceCMYK") => Some(4),
189        _ => None,
190    };
191    ImageDecodeParams {
192        is_indexed: cs.as_deref() == Some("Indexed"),
193        bpc: dict.get::<u8>(b"BitsPerComponent"),
194        num_components,
195        width: dict.get::<u32>(b"Width").unwrap_or(0),
196        height: dict.get::<u32>(b"Height").unwrap_or(0),
197        ..ImageDecodeParams::default()
198    }
199}
200
201/// The color space's family name: the name itself, or the first element of
202/// an array color space such as `[/Indexed ...]` or `[/ICCBased ...]`.
203fn color_space_family(dict: &Dict<'_>) -> Option<String> {
204    if let Some(name) = dict.get::<Name<'_>>(b"ColorSpace") {
205        return Some(name.as_str().to_owned());
206    }
207    let array = dict.get::<Array<'_>>(b"ColorSpace")?;
208    let first = array.iter::<Name<'_>>().next()?;
209    Some(first.as_str().to_owned())
210}
211
212#[cfg(test)]
213mod tests {
214    use lopdf::dictionary;
215
216    use super::*;
217
218    #[test]
219    fn garbage_does_not_verify() {
220        let v = verify(b"not a pdf at all", 1);
221        assert!(!v.is_ok());
222        assert!(v.problems[0].contains("does not parse"), "{v}");
223    }
224
225    #[test]
226    fn minimal_document_verifies() {
227        let mut doc = lopdf::Document::with_version("1.5");
228        let content = doc.add_object(lopdf::Stream::new(
229            dictionary! {},
230            b"0 0 m 10 10 l S".to_vec(),
231        ));
232        let pages_id = doc.new_object_id();
233        let page = doc.add_object(dictionary! {
234            "Type" => "Page",
235            "Parent" => pages_id,
236            "MediaBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
237            "Contents" => content,
238        });
239        doc.objects.insert(
240            pages_id,
241            lopdf::Object::Dictionary(dictionary! {
242                "Type" => "Pages",
243                "Kids" => vec![page.into()],
244                "Count" => 1,
245            }),
246        );
247        let catalog = doc.add_object(dictionary! {
248            "Type" => "Catalog",
249            "Pages" => pages_id,
250        });
251        doc.trailer.set("Root", catalog);
252        let mut bytes = Vec::new();
253        doc.save_to(&mut bytes).unwrap();
254
255        let v = verify(&bytes, 1);
256        assert!(v.is_ok(), "{v}");
257        assert_eq!(v.pages, 1);
258        assert!(!verify(&bytes, 2).is_ok());
259    }
260}