1pub 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
21const 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#[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 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
119pub 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
181fn 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
201fn 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}