1use pdf_interpret::InterpreterWarning;
22use pdf_syntax::leniency::{LeniencyEvent, LeniencySeverity};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[non_exhaustive]
27pub enum Severity {
28 Info,
31 Warning,
34 Error,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum DiagnosticCategory {
43 Font,
45 Image,
47 Limit,
49 Repair,
51 Decode,
53 Other,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub struct Diagnostic {
65 pub severity: Severity,
67 pub category: DiagnosticCategory,
69 pub code: &'static str,
72 pub message: String,
74 pub page: Option<usize>,
76 pub object: Option<u32>,
78 pub source: Option<String>,
80}
81
82impl Diagnostic {
83 pub const CODE_FONT_UNSUPPORTED: &'static str = "FONT_UNSUPPORTED";
85 pub const CODE_IMAGE_DECODE_FAILED: &'static str = "IMAGE_DECODE_FAILED";
87 pub const CODE_STREAM_TOO_LARGE: &'static str = "STREAM_TOO_LARGE";
89 pub const CODE_XREF_REBUILT: &'static str = "XREF_REBUILT";
91 pub const CODE_PAGE_TREE_REBUILT: &'static str = "PAGE_TREE_REBUILT";
93 pub const CODE_NESTING_TOO_DEEP: &'static str = "NESTING_TOO_DEEP";
96 pub const CODE_FLATE_BROKEN_FALLBACK: &'static str = "FLATE_BROKEN_FALLBACK";
98 pub const CODE_FLATE_BAD_BLOCK: &'static str = "FLATE_BAD_BLOCK";
100 pub const CODE_LZW_PREMATURE_EOF: &'static str = "LZW_PREMATURE_EOF";
102 pub const CODE_LZW_INVALID_CODE: &'static str = "LZW_INVALID_CODE";
104 pub const CODE_ASCII85_LENIENT_PARTIAL: &'static str = "ASCII85_LENIENT_PARTIAL";
106 pub const CODE_CCITT_PARTIAL_DECODE: &'static str = "CCITT_PARTIAL_DECODE";
108 pub const CODE_STREAM_PARSE_FALLBACK: &'static str = "STREAM_PARSE_FALLBACK";
110 pub const CODE_INDIRECT_CYCLE: &'static str = "INDIRECT_CYCLE";
112 pub const CODE_INDIRECT_DEPTH_EXCEEDED: &'static str = "INDIRECT_DEPTH_EXCEEDED";
114 pub const CODE_ANNOT_APPEARANCE_UNRESOLVED: &'static str = "ANNOT_APPEARANCE_UNRESOLVED";
116
117 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
258#[non_exhaustive]
259pub struct LeniencyReport {
260 pub events: Vec<Diagnostic>,
262 pub unique_event_count: usize,
264 pub warning_count: usize,
266 pub critical_count: usize,
268}
269
270impl LeniencyReport {
271 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 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 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 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}