1use std::collections::HashSet;
31
32use crate::error::PdfError;
33use crate::objects::{Dict, Object, ObjectId};
34use crate::reader::document::DocumentReader;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum IssueSeverity {
39 Error,
42 Warning,
46}
47
48#[derive(Debug, Clone)]
50pub struct HierarchyIssue {
51 pub object_id: Option<ObjectId>,
54 pub severity: IssueSeverity,
56 pub message: String,
58}
59
60#[derive(Debug, Clone, Default)]
62pub struct HierarchyReport {
63 pub page_count: usize,
65 pub max_depth: usize,
67 pub issues: Vec<HierarchyIssue>,
69}
70
71impl HierarchyReport {
72 pub fn is_valid(&self) -> bool {
74 !self
75 .issues
76 .iter()
77 .any(|i| i.severity == IssueSeverity::Error)
78 }
79
80 pub fn errors(&self) -> impl Iterator<Item = &HierarchyIssue> {
82 self.issues
83 .iter()
84 .filter(|i| i.severity == IssueSeverity::Error)
85 }
86
87 pub fn warnings(&self) -> impl Iterator<Item = &HierarchyIssue> {
89 self.issues
90 .iter()
91 .filter(|i| i.severity == IssueSeverity::Warning)
92 }
93}
94
95pub fn verify_hierarchy(reader: &mut DocumentReader<'_>) -> Result<HierarchyReport, PdfError> {
98 let mut report = HierarchyReport::default();
99
100 let root_id = reader.xref().root()?;
102 let catalog = reader.resolve(root_id)?;
103 let Object::Dict(catalog_dict) = catalog else {
104 report.issues.push(HierarchyIssue {
105 object_id: Some(root_id),
106 severity: IssueSeverity::Error,
107 message: "Catalog (/Root) must be a dictionary (§7.7.2 Table 28)".into(),
108 });
109 return Ok(report);
110 };
111
112 match lookup(&catalog_dict, "Type") {
114 Some(Object::Name(s)) if s == "Catalog" => {}
115 Some(other) => report.issues.push(HierarchyIssue {
116 object_id: Some(root_id),
117 severity: IssueSeverity::Error,
118 message: format!("Catalog /Type must be /Catalog (§7.7.2 Table 28) — got {other:?}"),
119 }),
120 None => report.issues.push(HierarchyIssue {
121 object_id: Some(root_id),
122 severity: IssueSeverity::Warning,
123 message: "Catalog missing /Type entry (§7.7.2 Table 28)".into(),
124 }),
125 }
126
127 let pages_root_id = match lookup(&catalog_dict, "Pages") {
129 Some(Object::Reference(id)) => *id,
130 Some(other) => {
131 report.issues.push(HierarchyIssue {
132 object_id: Some(root_id),
133 severity: IssueSeverity::Error,
134 message: format!(
135 "Catalog /Pages must be an indirect reference (§7.7.2 Table 28) — got {other:?}"
136 ),
137 });
138 return Ok(report);
139 }
140 None => {
141 report.issues.push(HierarchyIssue {
142 object_id: Some(root_id),
143 severity: IssueSeverity::Error,
144 message: "Catalog missing required /Pages entry (§7.7.2 Table 28)".into(),
145 });
146 return Ok(report);
147 }
148 };
149
150 let mut visited: HashSet<u32> = HashSet::new();
152 let mut leaves = 0usize;
153 let mut max_depth = 0usize;
154 walk_pages_node(
155 reader,
156 pages_root_id,
157 None,
158 0,
159 &mut visited,
160 &mut leaves,
161 &mut max_depth,
162 &mut report,
163 )?;
164 report.page_count = leaves;
165 report.max_depth = max_depth;
166 if leaves == 0 {
167 report.issues.push(HierarchyIssue {
168 object_id: Some(pages_root_id),
169 severity: IssueSeverity::Error,
170 message: "Pages tree contained no Page leaves (§7.7.3.3)".into(),
171 });
172 }
173 Ok(report)
174}
175
176#[allow(clippy::too_many_arguments)]
177fn walk_pages_node(
178 reader: &mut DocumentReader<'_>,
179 node_id: ObjectId,
180 parent_expected: Option<ObjectId>,
181 depth: usize,
182 visited: &mut HashSet<u32>,
183 leaves: &mut usize,
184 max_depth: &mut usize,
185 report: &mut HierarchyReport,
186) -> Result<(), PdfError> {
187 if depth > *max_depth {
188 *max_depth = depth;
189 }
190 if depth > 32 {
192 report.issues.push(HierarchyIssue {
193 object_id: Some(node_id),
194 severity: IssueSeverity::Error,
195 message: format!(
196 "Pages-tree depth exceeded 32 at node {node_id:?} — refusing to recurse"
197 ),
198 });
199 return Ok(());
200 }
201 if !visited.insert(node_id.number) {
202 report.issues.push(HierarchyIssue {
203 object_id: Some(node_id),
204 severity: IssueSeverity::Error,
205 message: format!("Pages-tree cycle: node {node_id:?} visited twice (§7.7.3)"),
206 });
207 return Ok(());
208 }
209 let node = match reader.resolve(node_id) {
210 Ok(o) => o,
211 Err(e) => {
212 report.issues.push(HierarchyIssue {
213 object_id: Some(node_id),
214 severity: IssueSeverity::Error,
215 message: format!("Pages-tree node {node_id:?} unresolvable: {e}"),
216 });
217 return Ok(());
218 }
219 };
220 let Object::Dict(d) = node else {
221 report.issues.push(HierarchyIssue {
222 object_id: Some(node_id),
223 severity: IssueSeverity::Error,
224 message: format!("Pages-tree node {node_id:?} is not a dictionary"),
225 });
226 return Ok(());
227 };
228
229 let type_name = match lookup(&d, "Type") {
230 Some(Object::Name(s)) => Some(s.as_str()),
231 _ => None,
232 };
233
234 match type_name {
235 Some("Page") => {
236 *leaves += 1;
237 check_page_leaf(&d, node_id, parent_expected, report);
238 }
239 Some("Pages") | None => {
240 if type_name.is_none() {
241 report.issues.push(HierarchyIssue {
242 object_id: Some(node_id),
243 severity: IssueSeverity::Warning,
244 message: format!(
245 "Pages-tree node {node_id:?} missing /Type (§7.7.3.2 Table 29 — required)"
246 ),
247 });
248 }
249 check_pages_node(
250 reader,
251 &d,
252 node_id,
253 parent_expected,
254 depth,
255 visited,
256 leaves,
257 max_depth,
258 report,
259 )?;
260 }
261 Some(other) => {
262 report.issues.push(HierarchyIssue {
263 object_id: Some(node_id),
264 severity: IssueSeverity::Error,
265 message: format!(
266 "Pages-tree node {node_id:?} has unrecognised /Type /{other} (expected /Pages or /Page)"
267 ),
268 });
269 }
270 }
271 Ok(())
272}
273
274#[allow(clippy::too_many_arguments)]
275fn check_pages_node(
276 reader: &mut DocumentReader<'_>,
277 d: &Dict,
278 node_id: ObjectId,
279 parent_expected: Option<ObjectId>,
280 depth: usize,
281 visited: &mut HashSet<u32>,
282 leaves: &mut usize,
283 max_depth: &mut usize,
284 report: &mut HierarchyReport,
285) -> Result<(), PdfError> {
286 if let Some(expected) = parent_expected {
290 match lookup(d, "Parent") {
291 Some(Object::Reference(actual)) if *actual == expected => {}
292 Some(Object::Reference(actual)) => {
293 report.issues.push(HierarchyIssue {
294 object_id: Some(node_id),
295 severity: IssueSeverity::Warning,
296 message: format!(
297 "/Pages {node_id:?} /Parent points to {actual:?} but DFS parent is {expected:?}"
298 ),
299 });
300 }
301 Some(other) => {
302 report.issues.push(HierarchyIssue {
303 object_id: Some(node_id),
304 severity: IssueSeverity::Warning,
305 message: format!(
306 "/Pages {node_id:?} /Parent must be an indirect reference (got {other:?})"
307 ),
308 });
309 }
310 None => {
311 report.issues.push(HierarchyIssue {
312 object_id: Some(node_id),
313 severity: IssueSeverity::Warning,
314 message: format!(
315 "/Pages {node_id:?} missing /Parent (§7.7.3.2 Table 29 — required on non-root nodes)"
316 ),
317 });
318 }
319 }
320 }
321
322 let kids = match lookup(d, "Kids") {
324 Some(Object::Array(items)) => items.clone(),
325 Some(other) => {
326 report.issues.push(HierarchyIssue {
327 object_id: Some(node_id),
328 severity: IssueSeverity::Error,
329 message: format!("/Pages {node_id:?} /Kids must be an array (got {other:?})"),
330 });
331 return Ok(());
332 }
333 None => {
334 report.issues.push(HierarchyIssue {
335 object_id: Some(node_id),
336 severity: IssueSeverity::Error,
337 message: format!("/Pages {node_id:?} missing required /Kids"),
338 });
339 return Ok(());
340 }
341 };
342
343 let leaves_before = *leaves;
344 for kid in kids {
345 let Object::Reference(kid_id) = kid else {
346 report.issues.push(HierarchyIssue {
347 object_id: Some(node_id),
348 severity: IssueSeverity::Error,
349 message: format!(
350 "/Pages {node_id:?} /Kids entry must be an indirect reference (got {kid:?})"
351 ),
352 });
353 continue;
354 };
355 walk_pages_node(
356 reader,
357 kid_id,
358 Some(node_id),
359 depth + 1,
360 visited,
361 leaves,
362 max_depth,
363 report,
364 )?;
365 }
366 let descendants = *leaves - leaves_before;
367
368 match lookup(d, "Count") {
372 Some(Object::Integer(n)) => {
373 if *n as usize != descendants {
374 report.issues.push(HierarchyIssue {
375 object_id: Some(node_id),
376 severity: IssueSeverity::Warning,
377 message: format!(
378 "/Pages {node_id:?} /Count = {n} but DFS found {descendants} leaves"
379 ),
380 });
381 }
382 }
383 Some(other) => {
384 report.issues.push(HierarchyIssue {
385 object_id: Some(node_id),
386 severity: IssueSeverity::Warning,
387 message: format!("/Pages {node_id:?} /Count must be an integer (got {other:?})"),
388 });
389 }
390 None => {
391 report.issues.push(HierarchyIssue {
392 object_id: Some(node_id),
393 severity: IssueSeverity::Warning,
394 message: format!("/Pages {node_id:?} missing required /Count (§7.7.3.2 Table 29)"),
395 });
396 }
397 }
398 Ok(())
399}
400
401fn check_page_leaf(
402 d: &Dict,
403 node_id: ObjectId,
404 parent_expected: Option<ObjectId>,
405 report: &mut HierarchyReport,
406) {
407 match (lookup(d, "Parent"), parent_expected) {
409 (Some(Object::Reference(actual)), Some(expected)) if *actual == expected => {}
410 (Some(Object::Reference(actual)), Some(expected)) => {
411 report.issues.push(HierarchyIssue {
412 object_id: Some(node_id),
413 severity: IssueSeverity::Warning,
414 message: format!(
415 "/Page {node_id:?} /Parent {actual:?} doesn't match DFS parent {expected:?}"
416 ),
417 });
418 }
419 (Some(other), _) => {
420 report.issues.push(HierarchyIssue {
421 object_id: Some(node_id),
422 severity: IssueSeverity::Warning,
423 message: format!(
424 "/Page {node_id:?} /Parent must be an indirect reference (got {other:?})"
425 ),
426 });
427 }
428 (None, _) => {
429 report.issues.push(HierarchyIssue {
430 object_id: Some(node_id),
431 severity: IssueSeverity::Warning,
432 message: format!(
433 "/Page {node_id:?} missing /Parent (§7.7.3.3 Table 30 — required)"
434 ),
435 });
436 }
437 }
438 if lookup(d, "MediaBox").is_none() {
442 report.issues.push(HierarchyIssue {
443 object_id: Some(node_id),
444 severity: IssueSeverity::Warning,
445 message: format!(
446 "/Page {node_id:?} has no directly-attached /MediaBox (inheritance may still satisfy §7.7.3.3)"
447 ),
448 });
449 }
450}
451
452fn lookup<'d>(d: &'d Dict, k: &str) -> Option<&'d Object> {
453 d.entries().iter().find(|(kk, _)| kk == k).map(|(_, v)| v)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use crate::writer::write_pdf_from_scene;
460 use oxideav_core::time::TimeBase;
461 use oxideav_core::vector::{
462 FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
463 };
464 use oxideav_scene::{Page, Scene};
465
466 fn page_with(w: f32, h: f32, color: Rgba) -> Page {
467 let mut p = Path::new();
468 p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
469 p.commands
470 .push(PathCommand::LineTo(Point::new(w - 10.0, 10.0)));
471 p.commands
472 .push(PathCommand::LineTo(Point::new(w - 10.0, h - 10.0)));
473 p.commands.push(PathCommand::Close);
474 let frame = VectorFrame {
475 width: w,
476 height: h,
477 view_box: None,
478 root: Group {
479 children: vec![Node::Path(PathNode {
480 path: p,
481 fill: Some(Paint::Solid(color)),
482 stroke: None,
483 fill_rule: FillRule::NonZero,
484 })],
485 ..Group::default()
486 },
487 pts: None,
488 time_base: TimeBase::new(1, 1),
489 };
490 let mut page = Page::new(w, h);
491 page.content = frame;
492 page
493 }
494
495 #[test]
496 fn writer_output_passes_hierarchy_check() {
497 let scene = Scene {
498 pages: Some(vec![
499 page_with(100.0, 100.0, Rgba::opaque(255, 0, 0)),
500 page_with(200.0, 200.0, Rgba::opaque(0, 255, 0)),
501 ]),
502 ..Scene::default()
503 };
504 let pdf = write_pdf_from_scene(&scene).expect("write_pdf");
505 let mut reader = DocumentReader::open(&pdf).expect("open");
506 let report = verify_hierarchy(&mut reader).expect("verify");
507 assert_eq!(report.page_count, 2);
508 assert!(
509 report.errors().count() == 0,
510 "writer output must have no hierarchy errors; got {:?}",
511 report.issues
512 );
513 assert!(report.is_valid());
514 }
515
516 #[test]
517 fn writer_single_page_reports_one_leaf() {
518 let scene = Scene {
519 pages: Some(vec![page_with(100.0, 100.0, Rgba::opaque(0, 0, 0))]),
520 ..Scene::default()
521 };
522 let pdf = write_pdf_from_scene(&scene).expect("write_pdf");
523 let mut reader = DocumentReader::open(&pdf).expect("open");
524 let report = verify_hierarchy(&mut reader).expect("verify");
525 assert_eq!(report.page_count, 1);
526 assert!(report.is_valid());
527 }
528
529 #[test]
530 fn report_is_valid_no_errors_default() {
531 let report = HierarchyReport::default();
532 assert!(report.is_valid());
533 assert_eq!(report.page_count, 0);
534 assert_eq!(report.max_depth, 0);
535 }
536
537 #[test]
538 fn report_distinguishes_errors_from_warnings() {
539 let mut report = HierarchyReport::default();
540 report.issues.push(HierarchyIssue {
541 object_id: None,
542 severity: IssueSeverity::Warning,
543 message: "warn".into(),
544 });
545 assert!(report.is_valid(), "warnings don't invalidate report");
546 report.issues.push(HierarchyIssue {
547 object_id: None,
548 severity: IssueSeverity::Error,
549 message: "err".into(),
550 });
551 assert!(!report.is_valid(), "errors invalidate report");
552 assert_eq!(report.errors().count(), 1);
553 assert_eq!(report.warnings().count(), 1);
554 }
555}