Skip to main content

oxideav_pdf/reader/
hierarchy.rs

1//! Round-27 — Object-hierarchy validator (ISO 32000-1 §7.7.2 + §7.7.3).
2//!
3//! Walks the `Catalog → Pages → Page` chain and surfaces every
4//! structural integrity problem a downstream tool would care about
5//! WITHOUT failing the whole open. Returns a [`HierarchyReport`]
6//! collecting per-node issues so callers can branch on severity
7//! (errors → fail; warnings → log + proceed).
8//!
9//! The checks are the structural-soundness invariants the spec
10//! mandates but the writer-symmetric reader currently doesn't
11//! verify explicitly:
12//!
13//! | Check | Severity | Reference |
14//! |-------|----------|-----------|
15//! | Catalog `/Type` = `/Catalog` | Error | §7.7.2 Table 28 |
16//! | Catalog has `/Pages` reference | Error | §7.7.2 Table 28 |
17//! | Pages root resolves to a `/Type /Pages` dict | Error | §7.7.3 Table 29 |
18//! | Pages-node `/Type` = `/Pages` (or absent) | Warning | §7.7.3 Table 29 |
19//! | Pages-node `/Count` matches actual leaves | Warning | §7.7.3 Table 29 |
20//! | Page leaf `/Parent` references its parent | Warning | §7.7.3 Table 30 |
21//! | Page-tree depth ≤ 32 (cycle guard) | Error | implementation |
22//! | All `/Kids` entries resolve to dictionaries | Error | §7.7.3 |
23//! | No cycles in the `/Kids` graph | Error | implementation |
24//!
25//! The validator is independent of the [`crate::reader::document`]
26//! page walker — that one is permissive (default MediaBox of A4,
27//! tolerant of missing `/Type`); this one surfaces every divergence
28//! from the spec letter.
29
30use std::collections::HashSet;
31
32use crate::error::PdfError;
33use crate::objects::{Dict, Object, ObjectId};
34use crate::reader::document::DocumentReader;
35
36/// Severity tag for [`HierarchyIssue`].
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum IssueSeverity {
39    /// Spec violation that breaks rendering — e.g. a `/Pages` tree
40    /// with no leaves, or a missing required `/Root` reference.
41    Error,
42    /// Spec divergence the reader can tolerate — e.g. a `/Pages`
43    /// node missing its `/Type` tag, or a `/Count` that doesn't
44    /// match the actual leaves.
45    Warning,
46}
47
48/// One issue surfaced by [`verify_hierarchy`].
49#[derive(Debug, Clone)]
50pub struct HierarchyIssue {
51    /// The offending object — `None` for issues that fault the
52    /// document as a whole (e.g. "Catalog missing /Pages").
53    pub object_id: Option<ObjectId>,
54    /// Severity flag — see [`IssueSeverity`].
55    pub severity: IssueSeverity,
56    /// Human-readable description naming the spec section.
57    pub message: String,
58}
59
60/// Summary report from [`verify_hierarchy`].
61#[derive(Debug, Clone, Default)]
62pub struct HierarchyReport {
63    /// Number of page leaves the walker found.
64    pub page_count: usize,
65    /// Maximum DFS depth reached during the walk.
66    pub max_depth: usize,
67    /// Every integrity finding, in walker order.
68    pub issues: Vec<HierarchyIssue>,
69}
70
71impl HierarchyReport {
72    /// True when no errors were found. Warnings are allowed.
73    pub fn is_valid(&self) -> bool {
74        !self
75            .issues
76            .iter()
77            .any(|i| i.severity == IssueSeverity::Error)
78    }
79
80    /// Convenience: filter by severity.
81    pub fn errors(&self) -> impl Iterator<Item = &HierarchyIssue> {
82        self.issues
83            .iter()
84            .filter(|i| i.severity == IssueSeverity::Error)
85    }
86
87    /// Convenience: filter by severity.
88    pub fn warnings(&self) -> impl Iterator<Item = &HierarchyIssue> {
89        self.issues
90            .iter()
91            .filter(|i| i.severity == IssueSeverity::Warning)
92    }
93}
94
95/// Walk `Catalog → Pages → Page` and collect every spec-deviation
96/// observed along the way.
97pub fn verify_hierarchy(reader: &mut DocumentReader<'_>) -> Result<HierarchyReport, PdfError> {
98    let mut report = HierarchyReport::default();
99
100    // ---- Catalog (§7.7.2) -----------------------------------------
101    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    // Catalog /Type
113    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    // Catalog /Pages
128    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    // ---- Pages tree (§7.7.3) --------------------------------------
151    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        /*parent_expected=*/ 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    // Cycle / runaway guard.
191    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    // /Parent — required on non-root /Pages nodes (Table 29). The
287    // root /Pages node has no parent — `parent_expected` is None
288    // there. We only report when there *should* be a parent.
289    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    // /Kids — required, array of references.
323    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    // /Count — must equal the number of /Page leaves under this node
369    // (§7.7.3.2 — "the number of leaf nodes [Page objects] that are
370    // descendants of this node within the page tree").
371    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    // /Parent — required on Page leaves (Table 30).
408    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    // MediaBox — required on the leaf OR inheritable from an
439    // ancestor /Pages node. We can't easily check inheritance here,
440    // so a missing MediaBox is a Warning rather than Error.
441    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}