Skip to main content

stet_pdf_reader/
diagnostics.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Parse-time diagnostics — non-fatal warnings the structural parsers
6//! emit when they encounter recoverable malformations.
7//!
8//! Every accessor on [`PdfDocument`] is fail-soft: it returns valid
9//! data on a best-effort basis and skips entries it can't interpret.
10//! When something is *skipped*, a [`ParseWarning`] is recorded so
11//! callers can surface a warning to their users (e.g. "this PDF's
12//! outline contained a cycle and was truncated at depth N") without
13//! the absence of the data being silent.
14//!
15//! Warnings accumulate on the `PdfDocument` as accessors are called
16//! for the first time; subsequent cached calls don't re-emit. Use
17//! [`PdfDocument::parse_warnings`] to inspect.
18//!
19//! [`PdfDocument`]: crate::PdfDocument
20//! [`PdfDocument::parse_warnings`]: crate::PdfDocument::parse_warnings
21
22use std::cell::RefCell;
23
24/// One non-fatal parsing problem.
25#[derive(Debug, Clone, PartialEq)]
26pub struct ParseWarning {
27    /// Which structural area produced this warning.
28    pub phase: ParsePhase,
29    /// Where in the document the problem was — page index, object
30    /// number, or the field name (for AcroForm warnings).
31    pub location: Option<LocationHint>,
32    /// Human-readable message.
33    pub message: String,
34    /// How seriously to surface this. The reader itself does not act
35    /// on severity; it's purely a hint for consumers building UI or
36    /// log output.
37    pub severity: Severity,
38}
39
40/// The structural area a warning came from.
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum ParsePhase {
44    Metadata,
45    ViewerPreferences,
46    Outline,
47    Destinations,
48    Annotations {
49        page: usize,
50    },
51    Form,
52    PageBoxes {
53        page: usize,
54    },
55    EmbeddedFiles,
56    /// Optional Content (layers): metadata, hierarchy, configurations.
57    Layers,
58}
59
60/// Where in the document a problem occurred.
61#[derive(Debug, Clone, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum LocationHint {
64    /// 0-based page index.
65    Page(usize),
66    /// Indirect object reference.
67    Object { obj_num: u32, gen_num: u16 },
68    /// Fully-qualified form-field name.
69    FieldName(String),
70    /// Outline-item title (the closest thing to a stable ID outline
71    /// entries have).
72    OutlineTitle(String),
73    /// Embedded-file or named-destination key.
74    Name(String),
75}
76
77/// Severity hint for consumers. The reader treats all of these the
78/// same internally; they only inform UI/log presentation.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum Severity {
82    /// Worth noting but the data is fine — e.g. "this PDF used a
83    /// truncated date string but we recovered the year/month".
84    Info,
85    /// Some data was dropped or defaulted — e.g. "annotation
86    /// without /Rect was skipped".
87    Warning,
88    /// A whole structural area couldn't be parsed at all — e.g.
89    /// "name tree was too deep and traversal stopped".
90    Error,
91}
92
93/// Internal accumulator used by parsers to record warnings.
94///
95/// Wraps a [`RefCell`] so the document-level accessor closures can
96/// pass `&WarningSink` to parsers without juggling exclusive
97/// references. Pushes are interior-mutable; the document's
98/// `parse_warnings()` accessor reads from the same cell.
99#[derive(Debug, Default)]
100pub struct WarningSink {
101    inner: RefCell<Vec<ParseWarning>>,
102}
103
104impl WarningSink {
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Push a warning into the sink.
110    pub fn push(&self, w: ParseWarning) {
111        self.inner.borrow_mut().push(w);
112    }
113
114    /// Convenience: build and push a warning with the given pieces.
115    pub fn record(
116        &self,
117        phase: ParsePhase,
118        location: Option<LocationHint>,
119        severity: Severity,
120        message: impl Into<String>,
121    ) {
122        self.push(ParseWarning {
123            phase,
124            location,
125            severity,
126            message: message.into(),
127        });
128    }
129
130    /// Borrow the underlying slice for read-only access. Held borrow
131    /// blocks further pushes until dropped, but parsers rarely hold
132    /// this — they only push.
133    pub fn borrow_slice(&self) -> std::cell::Ref<'_, [ParseWarning]> {
134        std::cell::Ref::map(self.inner.borrow(), Vec::as_slice)
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn record_and_borrow() {
144        let sink = WarningSink::new();
145        sink.record(
146            ParsePhase::Outline,
147            Some(LocationHint::OutlineTitle("Chapter 1".to_string())),
148            Severity::Warning,
149            "cycle detected; truncating",
150        );
151        sink.record(
152            ParsePhase::Annotations { page: 3 },
153            None,
154            Severity::Info,
155            "missing /Rect; entry skipped",
156        );
157
158        let view = sink.borrow_slice();
159        assert_eq!(view.len(), 2);
160        assert_eq!(view[0].phase, ParsePhase::Outline);
161        assert_eq!(view[0].severity, Severity::Warning);
162        assert_eq!(view[1].phase, ParsePhase::Annotations { page: 3 });
163        assert_eq!(view[1].severity, Severity::Info);
164    }
165
166    #[test]
167    fn location_hint_variants_are_distinguishable() {
168        let p = LocationHint::Page(5);
169        let o = LocationHint::Object {
170            obj_num: 42,
171            gen_num: 0,
172        };
173        let f = LocationHint::FieldName("user.email".to_string());
174        let n = LocationHint::Name("attachment.csv".to_string());
175        assert_ne!(p, o);
176        assert_ne!(o, f);
177        assert_ne!(f, n);
178    }
179}