Skip to main content

pdfluent/
diagnostics.rs

1//! Public diagnostics model.
2//!
3//! A stable, engine-agnostic view of the warnings, fallbacks and degradations
4//! that occurred while processing a document — font substitutions, dropped
5//! images, exceeded resource limits. Diagnostics let an enterprise caller tell
6//! a clean render apart from a silently-degraded one.
7//!
8//! Collected diagnostics are read via [`PdfDocument::diagnostics`] and
9//! [`PdfDocument::take_diagnostics`]. The model never exposes internal engine
10//! types, so it stays stable across releases.
11//!
12//! [`PdfDocument::diagnostics`]: crate::PdfDocument::diagnostics
13//! [`PdfDocument::take_diagnostics`]: crate::PdfDocument::take_diagnostics
14
15// Copyright (c) 2026 Innovation Trigger B.V.
16//
17// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
18// the PDFluent Commercial Licence. See the LICENSE file in this repository --
19// that file travels with the copy you received, which a URL does not.
20
21use pdf_interpret::InterpreterWarning;
22use pdf_syntax::leniency::{LeniencyEvent, LeniencySeverity};
23
24/// How serious a [`Diagnostic`] is.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[non_exhaustive]
27pub enum Severity {
28    /// Informational: a recovery or fallback succeeded; output may differ
29    /// slightly from the source.
30    Info,
31    /// A degradation occurred — content was substituted or dropped — but
32    /// processing continued and produced output.
33    Warning,
34    /// A hard limit or failure stopped the operation; the result is incomplete
35    /// or the call returned an error.
36    Error,
37}
38
39/// What area a [`Diagnostic`] concerns.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum DiagnosticCategory {
43    /// Font loading or substitution.
44    Font,
45    /// Image decoding or dropping.
46    Image,
47    /// A processing-limit / resource cap was hit.
48    Limit,
49    /// Structural repair or recovery (e.g. xref / page-tree reconstruction).
50    Repair,
51    /// Stream or content decoding.
52    Decode,
53    /// Anything not covered by the categories above.
54    Other,
55}
56
57/// A single diagnostic: something the engine recovered from, substituted, or
58/// dropped while processing a document.
59///
60/// Constructed only by the SDK; callers inspect the fields. The struct is
61/// `#[non_exhaustive]` so future fields do not break callers.
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub struct Diagnostic {
65    /// Severity.
66    pub severity: Severity,
67    /// Category.
68    pub category: DiagnosticCategory,
69    /// Stable, machine-readable code (e.g. `"IMAGE_DECODE_FAILED"`). Stable
70    /// across releases; safe to match on programmatically.
71    pub code: &'static str,
72    /// Human-readable description.
73    pub message: String,
74    /// 0-based page index this diagnostic relates to, if known.
75    pub page: Option<usize>,
76    /// PDF object number this diagnostic relates to, if known.
77    pub object: Option<u32>,
78    /// Optional free-form source/context hint.
79    pub source: Option<String>,
80}
81
82impl Diagnostic {
83    /// Stable code for an unsupported font that was substituted with a fallback.
84    pub const CODE_FONT_UNSUPPORTED: &'static str = "FONT_UNSUPPORTED";
85    /// Stable code for an image that could not be decoded and was omitted.
86    pub const CODE_IMAGE_DECODE_FAILED: &'static str = "IMAGE_DECODE_FAILED";
87    /// Stable code for a stream whose decompressed size exceeded the limit.
88    pub const CODE_STREAM_TOO_LARGE: &'static str = "STREAM_TOO_LARGE";
89    /// Stable code for an invalid cross-reference table that was rebuilt.
90    pub const CODE_XREF_REBUILT: &'static str = "XREF_REBUILT";
91    /// Stable code for an invalid page tree recovered by brute-force scan.
92    pub const CODE_PAGE_TREE_REBUILT: &'static str = "PAGE_TREE_REBUILT";
93    /// Content stream nesting hit the interpreter's depth limit; the rest of
94    /// that branch was not drawn (#318).
95    pub const CODE_NESTING_TOO_DEEP: &'static str = "NESTING_TOO_DEEP";
96    /// Stable code for a flate stream decoded via the pure-Rust fallback.
97    pub const CODE_FLATE_BROKEN_FALLBACK: &'static str = "FLATE_BROKEN_FALLBACK";
98    /// Stable code for a bad block header encountered in a flate stream.
99    pub const CODE_FLATE_BAD_BLOCK: &'static str = "FLATE_BAD_BLOCK";
100    /// Stable code for premature EOF in an LZW stream.
101    pub const CODE_LZW_PREMATURE_EOF: &'static str = "LZW_PREMATURE_EOF";
102    /// Stable code for an invalid code in an LZW stream.
103    pub const CODE_LZW_INVALID_CODE: &'static str = "LZW_INVALID_CODE";
104    /// Stable code for an ASCII-85 1-character terminal group accepted leniently.
105    pub const CODE_ASCII85_LENIENT_PARTIAL: &'static str = "ASCII85_LENIENT_PARTIAL";
106    /// Stable code for partial CCITT row decode.
107    pub const CODE_CCITT_PARTIAL_DECODE: &'static str = "CCITT_PARTIAL_DECODE";
108    /// Stable code for the stream manual fallback parser being used.
109    pub const CODE_STREAM_PARSE_FALLBACK: &'static str = "STREAM_PARSE_FALLBACK";
110    /// Stable code for a cycle detected in indirect object references.
111    pub const CODE_INDIRECT_CYCLE: &'static str = "INDIRECT_CYCLE";
112    /// Stable code for indirect object resolution depth exceeding 512.
113    pub const CODE_INDIRECT_DEPTH_EXCEEDED: &'static str = "INDIRECT_DEPTH_EXCEEDED";
114    /// Stable code for an annotation whose appearance could not be resolved.
115    pub const CODE_ANNOT_APPEARANCE_UNRESOLVED: &'static str = "ANNOT_APPEARANCE_UNRESOLVED";
116
117    /// Diagnostic for a cross-reference table rebuilt during load.
118    pub(crate) fn xref_rebuilt() -> Self {
119        Diagnostic {
120            severity: Severity::Warning,
121            category: DiagnosticCategory::Repair,
122            code: Self::CODE_XREF_REBUILT,
123            message: "The cross-reference table was invalid and was rebuilt by \
124                      scanning the file for objects; recovery may be incomplete."
125                .to_string(),
126            page: None,
127            object: None,
128            source: None,
129        }
130    }
131
132    /// Diagnostic for a page tree recovered by brute-force scan during load.
133    pub(crate) fn page_tree_rebuilt() -> Self {
134        Diagnostic {
135            severity: Severity::Warning,
136            category: DiagnosticCategory::Repair,
137            code: Self::CODE_PAGE_TREE_REBUILT,
138            message: "The page tree was invalid and pages were recovered by a \
139                      brute-force scan; page order may differ from the source."
140                .to_string(),
141            page: None,
142            object: None,
143            source: None,
144        }
145    }
146
147    /// Translate an internal interpreter warning into a stable public
148    /// diagnostic. Internal: the fork type never escapes the public surface.
149    pub(crate) fn from_interpreter_warning(warning: InterpreterWarning) -> Self {
150        match warning {
151            InterpreterWarning::UnsupportedFont => Diagnostic {
152                severity: Severity::Warning,
153                category: DiagnosticCategory::Font,
154                code: Self::CODE_FONT_UNSUPPORTED,
155                message: "An unsupported font was encountered; a fallback font \
156                          was substituted, so text may render differently from \
157                          the source."
158                    .to_string(),
159                page: None,
160                object: None,
161                source: None,
162            },
163            InterpreterWarning::ImageDecodeFailure => Diagnostic {
164                severity: Severity::Warning,
165                category: DiagnosticCategory::Image,
166                code: Self::CODE_IMAGE_DECODE_FAILED,
167                message: "An image could not be decoded and was omitted from the \
168                          output."
169                    .to_string(),
170                page: None,
171                object: None,
172                source: None,
173            },
174            InterpreterWarning::StreamTooLarge { observed, limit } => Diagnostic {
175                severity: Severity::Error,
176                category: DiagnosticCategory::Limit,
177                code: Self::CODE_STREAM_TOO_LARGE,
178                message: format!(
179                    "A stream's decompressed size ({observed} bytes) exceeded \
180                     the configured limit ({limit} bytes); the operation was \
181                     stopped."
182                ),
183                page: None,
184                object: None,
185                source: Some(format!("observed={observed};limit={limit}")),
186            },
187            InterpreterWarning::NestingTooDeep { limit } => Diagnostic {
188                // Warning, not Error: unlike StreamTooLarge the page still
189                // renders and the rest of it is correct -- what is missing is
190                // one branch that referred to itself. A caller rasterising for
191                // archival wants to know; one drawing a thumbnail does not
192                // need the call to fail.
193                severity: Severity::Warning,
194                category: DiagnosticCategory::Limit,
195                code: Self::CODE_NESTING_TOO_DEEP,
196                message: format!(
197                    "Content stream nesting exceeded the limit of {limit}; the \
198                     nested content was not drawn, so paint may be missing from \
199                     the page."
200                ),
201                page: None,
202                object: None,
203                source: Some(format!("limit={limit}")),
204            },
205            InterpreterWarning::UnresolvedAnnotationAppearance => Diagnostic {
206                severity: Severity::Warning,
207                category: DiagnosticCategory::Other,
208                code: Self::CODE_ANNOT_APPEARANCE_UNRESOLVED,
209                message: "An annotation has an appearance dictionary, but its \
210                          normal appearance could not be selected or loaded, so \
211                          the annotation was not drawn."
212                    .to_string(),
213                page: None,
214                object: None,
215                source: None,
216            },
217        }
218    }
219
220    /// Translate a low-level [`LeniencyEvent`] into a stable public diagnostic.
221    ///
222    /// Called by the document façade after draining the thread-local leniency
223    /// collector following a load or decode operation.
224    pub(crate) fn from_leniency_event(event: LeniencyEvent) -> Self {
225        let severity = match event.severity {
226            LeniencySeverity::Info => Severity::Info,
227            LeniencySeverity::Warning => Severity::Warning,
228            LeniencySeverity::Critical => Severity::Error,
229        };
230        let category = match event.code {
231            "FLATE_BROKEN_FALLBACK"
232            | "FLATE_BAD_BLOCK"
233            | "LZW_PREMATURE_EOF"
234            | "LZW_INVALID_CODE"
235            | "ASCII85_LENIENT_PARTIAL"
236            | "CCITT_PARTIAL_DECODE"
237            | "STREAM_PARSE_FALLBACK" => DiagnosticCategory::Decode,
238            "INDIRECT_CYCLE" | "INDIRECT_DEPTH_EXCEEDED" => DiagnosticCategory::Repair,
239            _ => DiagnosticCategory::Other,
240        };
241        Diagnostic {
242            severity,
243            category,
244            code: event.code,
245            message: event.message.to_string(),
246            page: None,
247            object: None,
248            source: None,
249        }
250    }
251}
252
253/// An aggregated summary of decode-leniency diagnostics for a document.
254///
255/// Computed from the diagnostics buffer via [`LeniencyReport::from_diagnostics`].
256/// Provides quick access to counts without iterating the full list.
257#[derive(Debug, Clone, PartialEq, Eq, Default)]
258#[non_exhaustive]
259pub struct LeniencyReport {
260    /// All leniency-related diagnostics (Repair + Decode categories).
261    pub events: Vec<Diagnostic>,
262    /// Number of distinct event codes present.
263    pub unique_event_count: usize,
264    /// Number of events at [`Severity::Warning`] level.
265    pub warning_count: usize,
266    /// Number of events at [`Severity::Error`] level (Critical in the parser layer).
267    pub critical_count: usize,
268}
269
270impl LeniencyReport {
271    /// Build a report from the diagnostics collected on a document.
272    ///
273    /// Filters to Repair and Decode categories (the leniency-relevant subset).
274    pub fn from_diagnostics(diagnostics: &[Diagnostic]) -> Self {
275        let events: Vec<Diagnostic> = diagnostics
276            .iter()
277            .filter(|d| {
278                matches!(
279                    d.category,
280                    DiagnosticCategory::Repair | DiagnosticCategory::Decode
281                )
282            })
283            .cloned()
284            .collect();
285        // Count distinct codes — `events` may contain one entry per render/extract
286        // call for the same code (each operation has its own activate/drain session).
287        let mut seen_codes = std::collections::HashSet::new();
288        for e in &events {
289            seen_codes.insert(e.code);
290        }
291        let unique_event_count = seen_codes.len();
292        let warning_count = events
293            .iter()
294            .filter(|d| d.severity == Severity::Warning)
295            .count();
296        let critical_count = events
297            .iter()
298            .filter(|d| d.severity == Severity::Error)
299            .count();
300        LeniencyReport {
301            events,
302            unique_event_count,
303            warning_count,
304            critical_count,
305        }
306    }
307
308    /// Returns `true` if no leniency events were recorded (clean document).
309    pub fn is_clean(&self) -> bool {
310        self.events.is_empty()
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn translation_maps_each_warning_to_a_stable_code() {
320        let f = Diagnostic::from_interpreter_warning(InterpreterWarning::UnsupportedFont);
321        assert_eq!(f.code, Diagnostic::CODE_FONT_UNSUPPORTED);
322        assert_eq!(f.category, DiagnosticCategory::Font);
323        assert_eq!(f.severity, Severity::Warning);
324
325        let i = Diagnostic::from_interpreter_warning(InterpreterWarning::ImageDecodeFailure);
326        assert_eq!(i.code, Diagnostic::CODE_IMAGE_DECODE_FAILED);
327        assert_eq!(i.category, DiagnosticCategory::Image);
328
329        let s = Diagnostic::from_interpreter_warning(InterpreterWarning::StreamTooLarge {
330            observed: 9000,
331            limit: 100,
332        });
333        assert_eq!(s.code, Diagnostic::CODE_STREAM_TOO_LARGE);
334        assert_eq!(s.category, DiagnosticCategory::Limit);
335        assert_eq!(s.severity, Severity::Error);
336        assert!(s.message.contains("9000"));
337        assert!(s.message.contains("100"));
338    }
339
340    #[test]
341    fn from_leniency_event_maps_severity_and_category() {
342        use pdf_syntax::leniency::{LeniencyEvent, LeniencySeverity};
343
344        let warn_event = LeniencyEvent {
345            code: "FLATE_BROKEN_FALLBACK",
346            severity: LeniencySeverity::Warning,
347            message: "test warn",
348        };
349        let d = Diagnostic::from_leniency_event(warn_event);
350        assert_eq!(d.code, "FLATE_BROKEN_FALLBACK");
351        assert_eq!(d.severity, Severity::Warning);
352        assert_eq!(d.category, DiagnosticCategory::Decode);
353
354        let critical_event = LeniencyEvent {
355            code: "INDIRECT_CYCLE",
356            severity: LeniencySeverity::Critical,
357            message: "test critical",
358        };
359        let d = Diagnostic::from_leniency_event(critical_event);
360        assert_eq!(d.code, "INDIRECT_CYCLE");
361        assert_eq!(d.severity, Severity::Error);
362        assert_eq!(d.category, DiagnosticCategory::Repair);
363
364        let info_event = LeniencyEvent {
365            code: "ASCII85_LENIENT_PARTIAL",
366            severity: LeniencySeverity::Info,
367            message: "test info",
368        };
369        let d = Diagnostic::from_leniency_event(info_event);
370        assert_eq!(d.severity, Severity::Info);
371        assert_eq!(d.category, DiagnosticCategory::Decode);
372    }
373
374    #[test]
375    fn leniency_report_filters_repair_and_decode_categories() {
376        let diagnostics = vec![
377            Diagnostic {
378                severity: Severity::Warning,
379                category: DiagnosticCategory::Font,
380                code: Diagnostic::CODE_FONT_UNSUPPORTED,
381                message: "font sub".to_string(),
382                page: None,
383                object: None,
384                source: None,
385            },
386            Diagnostic::xref_rebuilt(),
387            Diagnostic {
388                severity: Severity::Warning,
389                category: DiagnosticCategory::Decode,
390                code: Diagnostic::CODE_FLATE_BROKEN_FALLBACK,
391                message: "flate fallback".to_string(),
392                page: None,
393                object: None,
394                source: None,
395            },
396        ];
397
398        let report = LeniencyReport::from_diagnostics(&diagnostics);
399        // Font diag excluded; xref_rebuilt (Repair) + flate (Decode) included.
400        assert_eq!(report.events.len(), 2);
401        assert_eq!(report.unique_event_count, 2);
402        assert_eq!(report.warning_count, 2);
403        assert_eq!(report.critical_count, 0);
404        assert!(!report.is_clean());
405    }
406
407    #[test]
408    fn leniency_report_is_clean_when_no_repair_or_decode() {
409        let diagnostics = vec![Diagnostic {
410            severity: Severity::Warning,
411            category: DiagnosticCategory::Font,
412            code: Diagnostic::CODE_FONT_UNSUPPORTED,
413            message: "font sub".to_string(),
414            page: None,
415            object: None,
416            source: None,
417        }];
418        let report = LeniencyReport::from_diagnostics(&diagnostics);
419        assert!(report.is_clean());
420    }
421
422    #[test]
423    fn leniency_report_counts_severities_correctly() {
424        let diagnostics = vec![
425            Diagnostic {
426                severity: Severity::Warning,
427                category: DiagnosticCategory::Decode,
428                code: "CODE_A",
429                message: String::new(),
430                page: None,
431                object: None,
432                source: None,
433            },
434            Diagnostic {
435                severity: Severity::Error,
436                category: DiagnosticCategory::Repair,
437                code: "CODE_B",
438                message: String::new(),
439                page: None,
440                object: None,
441                source: None,
442            },
443            Diagnostic {
444                severity: Severity::Info,
445                category: DiagnosticCategory::Decode,
446                code: "CODE_C",
447                message: String::new(),
448                page: None,
449                object: None,
450                source: None,
451            },
452        ];
453        let report = LeniencyReport::from_diagnostics(&diagnostics);
454        assert_eq!(report.warning_count, 1);
455        assert_eq!(report.critical_count, 1);
456        assert_eq!(report.unique_event_count, 3);
457    }
458}