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}