pdfrum_common/diagnostics.rs
1//! The damage-tolerance channel.
2//!
3//! Opening broken PDFs is the behavior this project exists to reproduce, so a
4//! recovery is *not* an error: a function that can proceed past damage takes a
5//! `&mut Diagnostics`, records what it repaired, and returns the best-effort
6//! value. `Err` is reserved for "cannot continue". Nothing here ever fails, so
7//! recording a diagnostic never changes control flow.
8
9/// How badly a recorded event bends the file's meaning.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Severity {
12 /// The reader repaired the damage and is confident about the result
13 /// (a rebuilt cross-reference table, a `/Length` corrected by scanning
14 /// for `endstream`).
15 Recovered,
16 /// The reader proceeded, but the file said something it should not have
17 /// and information was dropped (a malformed dictionary entry skipped, an
18 /// out-of-range cross-reference-stream field ignored).
19 Suspicious,
20}
21
22/// What was repaired.
23///
24/// Grows as each crate lands, but **a variant earns its place by having a
25/// recording site**: it names a condition some crate actually detects and
26/// recovers from, and there is a `record` call to prove it. `#[non_exhaustive]`
27/// makes both directions non-breaking, and the enum has shrunk as well as
28/// grown: a variant is added when a port reaches the condition, not before.
29///
30/// A variant with no recording site is worse than no variant: it reads as a
31/// promise that `Document::diagnostics()` reports the condition, and a caller
32/// matching on it waits for a row that never comes.
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum DiagKind {
36 /// The `%PDF-` header was not at offset 0; the given offset became the
37 /// origin for every offset in the file.
38 HeaderOffset,
39 /// `startxref` was missing or pointed at something that is not a
40 /// cross-reference section.
41 BadStartXref,
42 /// The cross-reference table was rebuilt by scanning the whole file.
43 XrefRebuilt,
44 /// The `/Prev` chain of cross-reference sections looped back on itself.
45 XrefPrevLoop,
46 /// A cross-reference table's entries did not agree with the objects found
47 /// at the offsets they name.
48 XrefEntriesShifted,
49 /// A cross-reference stream entry was dropped (bad field type, generation
50 /// beyond `u16`, or a segment running past the stream).
51 XrefStreamEntryDropped,
52 /// The trailer's `/Root` was missing or unusable and the catalog was found
53 /// another way.
54 RootRecovered,
55 /// A stream's `/Length` did not match the bytes before `endstream`.
56 LengthMismatch,
57 /// An `endstream`/`endobj` keyword was missing and the reader resynced.
58 KeywordResync,
59 /// A dictionary body was malformed: closed by `endobj`, or a key/value
60 /// pair was unparsable and skipped.
61 MalformedDict,
62 /// An array element was unparsable and the array was kept partially.
63 MalformedArray,
64 /// A stream appeared as a dictionary value or array element, which
65 /// ISO 32000-1 §7.3.8.1 forbids; it was dropped.
66 StreamInCompositeDropped,
67 /// The object at a cross-reference offset carried a different object
68 /// number than the table claimed.
69 ObjNumMismatch,
70 /// An object-stream offset pair was garbage and that entry was skipped.
71 ObjStmEntryDropped,
72 /// A stream's filter chain was invalid, so the raw bytes were used.
73 UndecodableStream,
74 /// The page tree needed repair: a guessed `/Type`, a wrong `/Count`, or a
75 /// kid that pointed back at an ancestor.
76 PageTreeRepaired,
77 /// The page tree was deeper than the depth cap and the walk stopped.
78 PageTreeDepthExceeded,
79 /// The password was accepted only after re-encoding it.
80 PasswordReencoded,
81 /// An `/Encoding` named no built-in CMap; the font fell back to two-byte
82 /// codes mapped to themselves.
83 CMapNameUnknown,
84 /// An `/Encoding` name matched a known CMap family but no built-in table
85 /// carries that exact name, so the decoder is right and the CID map is not.
86 CMapTableMissing,
87 /// An embedded CMap's `usecmap`, or a stream's `/UseCMap`, named a CMap
88 /// that is not one of the built-in ones, so nothing was inherited.
89 CMapUsecmapUnknown,
90 /// A `/UseCMap` chain ran deeper than `Limits::max_name_tree_depth`; the
91 /// rest of the chain was not followed.
92 CMapUsecmapDepth,
93 /// A codespace range's bounds were discarded: a block declaring exactly
94 /// one range keeps only its width.
95 CMapCodespaceDropped,
96 /// A codespace bound had no closing `>` and was read at whatever width its
97 /// digits implied.
98 CMapTruncatedCodespace,
99 /// A `begincidrange` named a start code above its end code, so it mapped
100 /// nothing.
101 CMapReversedRange,
102 /// Character-code mappings at or above `0x1_0000` were dropped because the
103 /// CMap's coding scheme cannot produce codes that wide.
104 CMapWideMappingsDropped,
105 /// A CMap program declared more ranges than `Limits::max_cmap_ranges`
106 /// allows; the rest were dropped.
107 CMapRangeLimit,
108 /// More operands arrived for one CMap construct than it takes.
109 CMapOperandOverflow,
110 /// A PFB container's segment chain ended early: a length running past the
111 /// blob, a missing `0x80` marker, or no end-of-file record. The segments
112 /// read so far were kept.
113 Type1PfbTruncated,
114 /// A PFA font program's hexadecimal private section ended at a byte that
115 /// is not a hex digit, so the tail was dropped.
116 Type1HexTruncated,
117 /// A Type 1 `/Encoding` entry named a glyph the `/CharStrings` dictionary
118 /// does not define, so that character code maps to nothing.
119 Type1EncodingGlyphMissing,
120 /// A Type 1 charstring could not be interpreted to completion — an
121 /// unknown operator, a stack underflow, a missing subroutine, or a
122 /// recursion depth cap. Whatever path had been built is kept.
123 Type1CharstringAborted,
124 /// A Multiple-Master font's `/WeightVector`, `/BlendDesignPositions`,
125 /// `/BlendDesignMap` and `/BlendAxisTypes` did not agree on the number of
126 /// axes or masters, so the font was treated as non-variable.
127 Type1BlendInconsistent,
128 /// A `/ToUnicode` `bfchar` or `bfrange` block declared a different number
129 /// of entries than it contained, or contained a character code the format
130 /// cannot express, so **every mapping in that block** was discarded.
131 ToUnicodeBlockRejected,
132 /// An embedded font program could not be read by any backend, so the font
133 /// was treated as if it had none and went to substitution.
134 FontProgramUnreadable,
135 /// A `/CIDToGIDMap` stream was shorter than the CIDs indexing into it, so
136 /// glyphs past its end resolve to nothing.
137 CidToGidStreamShort,
138 /// A `/W`, `/W2` or `/Widths` array was malformed and parsing stopped
139 /// early or dropped a record; the widths read so far were kept.
140 FontWidthsTruncated,
141 /// An OpenType `GSUB` table could not be read, so vertical glyph
142 /// substitution is unavailable and upright forms are drawn instead.
143 GsubUnreadable,
144 /// No system or embedded face could be found for a font, and even the
145 /// built-in fallback failed to parse.
146 FontSubstitutionFailed,
147
148 // ---- Content streams and page building (`pdfrum-page`) ----
149 /// A content-stream keyword named no operator; it and its operands were
150 /// dropped.
151 UnknownOperator,
152 /// More than sixteen operands accumulated before one operator, so the
153 /// oldest were evicted and the rest silently renumbered.
154 OperandsDropped,
155 /// An operator that demands an exact operand count did not get it, so it
156 /// did nothing at all.
157 OperandCountMismatch,
158 /// A `Q` arrived with no matching `q`; the graphics state was left alone.
159 UnbalancedRestore,
160 /// An `EMC` arrived with no matching `BMC`/`BDC`.
161 UnbalancedMarkedContent,
162 /// A form `XObject` was refused because it re-entered a content buffer
163 /// already being parsed, or because too many parses were in flight. The
164 /// stream was consumed and contributed no objects.
165 FormRecursionRefused,
166 /// A `BI` was followed by a keyword other than `ID`, so the inline image
167 /// was abandoned and the bytes re-read as ordinary content.
168 InlineImageAbandoned,
169 /// The scan for an inline image's `EI` absorbed bytes past the end of its
170 /// inferred sample data.
171 InlineImageResync,
172 /// An inline image named a filter whose length cannot be inferred
173 /// (`JPXDecode`, `JBIG2Decode`, or an unknown name), so it produced
174 /// nothing.
175 InlineImageUnsupported,
176 /// A `Tr` operand outside 0..=7 was ignored, leaving the previous text
177 /// rendering mode in place.
178 BadTextRenderMode,
179 /// A dash pattern was abandoned in favour of a solid line: an element was
180 /// not finite, or the whole cycle fell below the device threshold.
181 DashPatternDropped,
182 /// A dash element at or below one part in a million was replaced by 0.1.
183 DashElementClamped,
184 /// A colorspace could not be built, so the operator naming it did
185 /// nothing.
186 ColorSpaceUnsupported,
187 /// An ICC profile was not usable and its `/Alternate` space was used
188 /// instead.
189 IccAlternateUsed,
190 /// An ICC profile was not usable and no `/Alternate` served, so the stock
191 /// device space for its `/N` was used.
192 IccStockFallback,
193 /// An ICC space's `/Alternate` declared a different component count than
194 /// its `/N`, so the alternate was discarded.
195 IccAlternateMismatch,
196 /// An `/Indexed` space's `/hival` was outside 0..=255 and was clamped.
197 IndexedHivalClamped,
198 /// A `Separation` space's tint transform failed to load or produced too
199 /// few outputs; the space kept working without it.
200 TintTransformDropped,
201 /// A function could not be built, so whatever named it has no transform.
202 FunctionUnsupported,
203 /// A PostScript calculator program pushed past its stack or popped an
204 /// empty one; the values involved were dropped or read as zero.
205 PostScriptStackAbuse,
206 /// A PostScript `if` or `ifelse` was not preceded by the procedures it
207 /// needs, aborting that procedure.
208 PostScriptMalformedProc,
209 /// A shading failed validation and paints nothing.
210 ShadingUnsupported,
211 /// A mesh shading's `/Decode` array was not exactly the length its
212 /// component count requires.
213 MeshDecodeMalformed,
214 /// A mesh stream ran out mid-record; the vertices read so far were kept.
215 MeshTruncated,
216 /// A tiling pattern's `/XStep` or `/YStep` was zero or not finite, so it
217 /// draws nothing.
218 TilingStepInvalid,
219 /// A tiling pattern's tile indices did not fit an `i32`, so it draws
220 /// nothing.
221 TilingRangeOverflow,
222 /// An image's `/BitsPerComponent` was not one of 1, 2, 4, 8 or 16.
223 ImageBadBitDepth,
224 /// An image's `/Width` or `/Height` was zero, negative, or beyond the
225 /// dimension cap.
226 ImageBadDimensions,
227 /// A codec reported dimensions differing from the image dictionary's, and
228 /// the codec's were used.
229 ImageDimensionsFromCodec,
230 /// A JPEG 2000 codestream's own colour space replaced the one the image
231 /// dictionary named.
232 JpxColorSpaceOverride,
233 /// A codec refused an embedded image, so it paints nothing.
234 ImageDecodeFailed,
235 /// An image's mask could not be loaded; the base image was kept unmasked.
236 MaskDropped,
237 /// An image's sample data ended before its last scanline; the remainder
238 /// was zero-filled.
239 ImageStreamTruncated,
240 /// A colour-key `/Mask` array held fewer than two entries per component,
241 /// so the ranges it did not state default to zero.
242 ColorKeyArrayShort,
243 /// A page's `/MediaBox` was missing or empty, so US Letter was used.
244 MediaBoxDefaulted,
245 /// An optional-content membership dictionary named a `/P` policy that is
246 /// none of the four defined ones, which makes its content invisible.
247 OptionalContentPolicyUnknown,
248
249 // ---- Text extraction (`pdfrum-text`) ----
250 // `core/fpdftext/` has no error channel at all: every damaged input there
251 // is a silent skip or a default value. These are those silences, named.
252 /// A text object's bounding box had no width, so the object was dropped
253 /// whole and contributed no characters.
254 TextObjectDegenerate,
255 /// A text object was dropped because the one before it showed no glyphs —
256 /// a quirk of the batching, not a property of the dropped object.
257 TextObjectDropped,
258 /// A text object repeated one of the five text objects before it closely
259 /// enough to be a redraw, and was dropped.
260 TextObjectDuplicate,
261 /// Character codes in one text object had no Unicode mapping and were
262 /// emitted as raw code points. Carries how many, because a font with a
263 /// broken `/ToUnicode` would otherwise record one per character and
264 /// flood the sink.
265 TextCharcodesUnmapped(u32),
266 /// Character code zero appeared, which emits a NUL into the character
267 /// stream and nothing into the text.
268 TextCharcodeZero,
269 /// A marked-content `/ActualText` held no printable character, so the
270 /// object it covered emitted nothing at all.
271 TextActualTextUnprintable,
272 /// An `/ActualText` character at or above `U+FFFD` was skipped, though
273 /// the box progression still stepped past it.
274 TextActualTextCharDropped,
275 /// A soft hyphen was called for with no preceding character to attach it
276 /// to. The C++ dereferences an empty container here; we emit nothing.
277 TextHyphenNoPrevChar,
278
279 // ---- Document features: navigation, annotations, forms, structure ----
280 /// An outline, `/Next` action chain, or field `/Parent` walk revisited a
281 /// node it had already seen; the walk stopped there.
282 NavigationCycle,
283 /// A name, number, structure or field tree exceeded its depth cap. The
284 /// lookup answers "not found" rather than recursing further.
285 TreeDepthExceeded,
286 /// A name-tree node's `/Limits` array was shorter than two entries, or
287 /// held its bounds the wrong way round, and was read as repaired.
288 NameTreeLimitsRepaired,
289 /// A name-tree leaf's `/Names` array had an odd length, so its last key
290 /// has no value.
291 NameTreeMalformed,
292 /// A named destination resolved only through the pre-1.2 `/Dests`
293 /// dictionary, not the name tree.
294 LegacyNamedDest,
295 /// A destination's page could not be turned into an index.
296 DestPageUnresolved,
297 /// An annotation's `/Subtype` matched no known spelling.
298 AnnotSubtypeUnknown,
299 /// An appearance stream was generated for an annotation that had none.
300 AppearanceGenerated,
301 /// A `/QuadPoints` array's length is not a multiple of eight; the tail is
302 /// ignored.
303 QuadPointsTruncated,
304 /// An `/InkList` sub-array was too short to draw, or had an odd length.
305 InkPathDropped,
306 /// A `/DA` string held no `Tf` operator, so the font name is empty and
307 /// the size is zero.
308 DefaultAppearanceMalformed,
309 /// `/DR /Font` is not a dictionary of font dictionaries, so no form
310 /// appearance can be generated.
311 FormResourcesInvalid,
312 /// A form field carries no `/FT` on itself or its parent.
313 FieldSkippedNoType,
314 /// A form field's fully-qualified name came out empty, so it was dropped:
315 /// the name is a field's identity and its only address.
316 FieldSkippedNoName,
317 /// A structure element was dropped: its page did not match, or its parent
318 /// could not be linked.
319 StructElementDropped,
320 /// A page label's `/S` names no known numbering style, so the label is
321 /// its prefix alone.
322 PageLabelStyleUnknown,
323
324 /// A document's script exhausted one of the [`Limits`](crate::Limits)
325 /// script bounds — loop iterations, recursion depth or stack — and was
326 /// stopped.
327 ///
328 /// **The hook it was running then takes its *refusing* answer**, not its
329 /// permissive one: a script that ran out of budget did not say "accept",
330 /// and inventing an acceptance on its behalf is what would let a hostile
331 /// file walk past a validator. A build without the script feature can
332 /// never record this.
333 ScriptLimitReached,
334 /// A document's script threw, or would not parse, and was abandoned.
335 ///
336 /// An ordinary outcome for untrusted input rather than an error: the rest
337 /// of the document is unaffected, and the hook takes its refusing answer
338 /// for the same reason as [`DiagKind::ScriptLimitReached`].
339 ScriptFailed,
340 /// `Limits::deadline` passed inside an operation that cannot fail — the
341 /// content interpreter or the text extractor — which stopped where it was
342 /// and returned what it had. The result is partial: the objects before
343 /// the stop, or an empty text page.
344 ///
345 /// Recorded once per stop. The fallible entry points (open, page load,
346 /// render) answer `LimitExceeded::Time` instead of recording this, so a
347 /// render that fails on the deadline still carries the interpreter's
348 /// record of where the build stopped.
349 TimeLimitReached,
350}
351
352impl core::fmt::Display for Severity {
353 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
354 f.write_str(match self {
355 Self::Recovered => "recovered",
356 Self::Suspicious => "suspicious",
357 })
358 }
359}
360
361impl DiagKind {
362 /// One lower-case clause naming what was repaired, for a human reading a
363 /// report.
364 ///
365 /// The `Debug` spelling is the variant identifier and is what `--json`
366 /// carries, because a machine consumer wants a token that survives
367 /// rewording; this is the other half, and the two are deliberately not
368 /// interchangeable.
369 ///
370 /// The match is exhaustive with no wildcard arm: `#[non_exhaustive]`
371 /// binds downstream crates, not this one, so a variant added without a
372 /// clause here fails to compile. That is the point — it is the same gate
373 /// the enum's own doc comment asks for, applied to the wording.
374 ///
375 /// ```
376 /// use pdfrum_common::DiagKind;
377 ///
378 /// assert_eq!(
379 /// DiagKind::XrefRebuilt.message(),
380 /// "cross-reference table rebuilt by scanning the file",
381 /// );
382 /// ```
383 #[must_use]
384 // One arm per variant: the length is the enum's, not the function's.
385 #[allow(clippy::too_many_lines)]
386 pub fn message(&self) -> &'static str {
387 match self {
388 Self::HeaderOffset => "%PDF- header was not at the start of the file",
389 Self::BadStartXref => "startxref was missing or did not name a cross-reference section",
390 Self::XrefRebuilt => "cross-reference table rebuilt by scanning the file",
391 Self::XrefPrevLoop => "cross-reference /Prev chain looped back on itself",
392 Self::XrefEntriesShifted => "cross-reference entries disagreed with the objects found",
393 Self::XrefStreamEntryDropped => "cross-reference stream entry dropped",
394 Self::RootRecovered => "trailer /Root was unusable; catalog found another way",
395 Self::LengthMismatch => "stream /Length did not match the bytes before endstream",
396 Self::KeywordResync => "missing endstream/endobj keyword; reader resynced",
397 Self::MalformedDict => "malformed dictionary body",
398 Self::MalformedArray => "malformed array element; array kept partially",
399 Self::StreamInCompositeDropped => "stream inside a dictionary or array dropped",
400 Self::ObjNumMismatch => "object number did not match the cross-reference table",
401 Self::ObjStmEntryDropped => "object-stream entry skipped",
402 Self::UndecodableStream => "stream filter chain was invalid; raw bytes used",
403 Self::PageTreeRepaired => "page tree repaired",
404 Self::PageTreeDepthExceeded => "page tree deeper than the depth cap; walk stopped",
405 Self::PasswordReencoded => "password accepted only after re-encoding",
406 Self::CMapNameUnknown => {
407 "/Encoding named no built-in CMap; fell back to two-byte codes"
408 }
409 Self::CMapTableMissing => "no built-in CMap table carries that exact name",
410 Self::CMapUsecmapUnknown => "usecmap named a CMap that is not built in",
411 Self::CMapUsecmapDepth => "/UseCMap chain ran past its depth cap",
412 Self::CMapCodespaceDropped => "codespace range bounds discarded",
413 Self::CMapTruncatedCodespace => "codespace bound had no closing >",
414 Self::CMapReversedRange => "begincidrange start code was above its end code",
415 Self::CMapWideMappingsDropped => {
416 "character mappings too wide for the coding scheme dropped"
417 }
418 Self::CMapRangeLimit => "CMap declared more ranges than the limit allows",
419 Self::CMapOperandOverflow => "too many operands for one CMap construct",
420 Self::Type1PfbTruncated => "PFB segment chain ended early",
421 Self::Type1HexTruncated => "PFA hexadecimal private section ended early",
422 Self::Type1EncodingGlyphMissing => "Type 1 /Encoding named an undefined glyph",
423 Self::Type1CharstringAborted => {
424 "Type 1 charstring could not be interpreted to completion"
425 }
426 Self::Type1BlendInconsistent => {
427 "Multiple-Master blend arrays disagreed; treated as non-variable"
428 }
429 Self::ToUnicodeBlockRejected => {
430 "/ToUnicode block rejected; its mappings were discarded"
431 }
432 Self::FontProgramUnreadable => "embedded font program unreadable; substituted instead",
433 Self::CidToGidStreamShort => {
434 "/CIDToGIDMap stream was shorter than the CIDs indexing it"
435 }
436 Self::FontWidthsTruncated => "font widths array was malformed; parsing stopped early",
437 Self::GsubUnreadable => "OpenType GSUB table unreadable; upright forms drawn",
438 Self::FontSubstitutionFailed => {
439 "no face found and the built-in fallback failed to parse"
440 }
441 Self::UnknownOperator => "content-stream keyword named no operator",
442 Self::OperandsDropped => "more than sixteen operands accumulated; oldest evicted",
443 Self::OperandCountMismatch => "operator did not get its exact operand count",
444 Self::UnbalancedRestore => "Q with no matching q",
445 Self::UnbalancedMarkedContent => "EMC with no matching BMC/BDC",
446 Self::FormRecursionRefused => "form XObject refused as re-entrant",
447 Self::InlineImageAbandoned => "BI was not followed by ID; inline image abandoned",
448 Self::InlineImageResync => "inline image EI scan ran past the inferred sample data",
449 Self::InlineImageUnsupported => "inline image filter length cannot be inferred",
450 Self::BadTextRenderMode => "Tr operand outside 0..=7 ignored",
451 Self::DashPatternDropped => "dash pattern abandoned for a solid line",
452 Self::DashElementClamped => "dash element below the threshold replaced by 0.1",
453 Self::ColorSpaceUnsupported => "colorspace could not be built",
454 Self::IccAlternateUsed => "ICC profile unusable; /Alternate space used",
455 Self::IccStockFallback => "ICC profile unusable; stock device space used",
456 Self::IccAlternateMismatch => "ICC /Alternate component count disagreed with /N",
457 Self::IndexedHivalClamped => "/Indexed /hival outside 0..=255 was clamped",
458 Self::TintTransformDropped => "Separation tint transform dropped",
459 Self::FunctionUnsupported => "function could not be built",
460 Self::PostScriptStackAbuse => "PostScript calculator over- or under-ran its stack",
461 Self::PostScriptMalformedProc => "PostScript if/ifelse lacked its procedures",
462 Self::ShadingUnsupported => "shading failed validation and paints nothing",
463 Self::MeshDecodeMalformed => "mesh shading /Decode array had the wrong length",
464 Self::MeshTruncated => "mesh stream ran out mid-record",
465 Self::TilingStepInvalid => "tiling pattern /XStep or /YStep was zero or not finite",
466 Self::TilingRangeOverflow => "tiling pattern tile indices did not fit an i32",
467 Self::ImageBadBitDepth => "image /BitsPerComponent was not 1, 2, 4, 8 or 16",
468 Self::ImageBadDimensions => "image /Width or /Height was zero, negative, or too large",
469 Self::ImageDimensionsFromCodec => "codec dimensions differed from the dictionary's",
470 Self::JpxColorSpaceOverride => {
471 "JPEG 2000 codestream colour space replaced the dictionary's"
472 }
473 Self::ImageDecodeFailed => "codec refused an embedded image",
474 Self::MaskDropped => "image mask could not be loaded; base image kept unmasked",
475 Self::ImageStreamTruncated => "image data ended early; remainder zero-filled",
476 Self::ColorKeyArrayShort => {
477 "colour-key /Mask array was short; missing ranges default to zero"
478 }
479 Self::MediaBoxDefaulted => "page /MediaBox was missing or empty; US Letter used",
480 Self::OptionalContentPolicyUnknown => {
481 "optional-content /P policy is not one of the four defined"
482 }
483 Self::TextObjectDegenerate => "text object had no width and was dropped",
484 Self::TextObjectDropped => {
485 "text object dropped because the one before it showed no glyphs"
486 }
487 Self::TextObjectDuplicate => "text object was a redraw of a recent one and was dropped",
488 Self::TextCharcodesUnmapped(_) => "character codes had no Unicode mapping",
489 Self::TextCharcodeZero => "character code zero emitted a NUL",
490 Self::TextActualTextUnprintable => "/ActualText held no printable character",
491 Self::TextActualTextCharDropped => "/ActualText character at or above U+FFFD skipped",
492 Self::TextHyphenNoPrevChar => "soft hyphen had no preceding character to attach to",
493 Self::NavigationCycle => "navigation walk revisited a node it had already seen",
494 Self::TreeDepthExceeded => "tree exceeded its depth cap; lookup answered not-found",
495 Self::NameTreeLimitsRepaired => "name-tree /Limits array was short or reversed",
496 Self::NameTreeMalformed => "name-tree /Names array had an odd length",
497 Self::LegacyNamedDest => {
498 "named destination resolved through the pre-1.2 /Dests dictionary"
499 }
500 Self::DestPageUnresolved => "destination page could not be turned into an index",
501 Self::AnnotSubtypeUnknown => "annotation /Subtype matched no known spelling",
502 Self::AppearanceGenerated => {
503 "appearance stream generated for an annotation that had none"
504 }
505 Self::QuadPointsTruncated => "/QuadPoints length was not a multiple of eight",
506 Self::InkPathDropped => "/InkList sub-array was too short or had an odd length",
507 Self::DefaultAppearanceMalformed => "/DA string held no Tf operator",
508 Self::FormResourcesInvalid => "/DR /Font is not a dictionary of font dictionaries",
509 Self::FieldSkippedNoType => "form field carries no /FT",
510 Self::FieldSkippedNoName => "form field's qualified name came out empty",
511 Self::StructElementDropped => "structure element dropped",
512 Self::PageLabelStyleUnknown => "page label /S names no known numbering style",
513 Self::ScriptLimitReached => "script exhausted a limit and was stopped",
514 Self::ScriptFailed => "script threw or would not parse and was abandoned",
515 Self::TimeLimitReached => "time limit passed; the result is partial",
516 }
517 }
518}
519
520impl core::fmt::Display for DiagKind {
521 /// The [`message`](DiagKind::message) clause, with any count the variant
522 /// carries appended: `character codes had no Unicode mapping (3)`.
523 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
524 f.write_str(self.message())?;
525 match self {
526 Self::TextCharcodesUnmapped(n) => write!(f, " ({n})"),
527 _ => Ok(()),
528 }
529 }
530}
531
532/// One recorded recovery.
533#[derive(Debug, Clone, PartialEq, Eq, Hash)]
534pub struct Diagnostic {
535 /// How badly the file was bent.
536 pub severity: Severity,
537 /// What was repaired.
538 pub what: DiagKind,
539 /// Byte offset into the file where the damage was seen, when known.
540 /// Offsets are header-relative, matching how the reader indexes the file.
541 pub at: Option<u64>,
542}
543
544/// A bounded sink of [`Diagnostic`]s.
545///
546/// Bounded because a sufficiently broken file produces a diagnostic per
547/// object: past the limit the entries are counted but not stored, so a fuzz
548/// case cannot turn a diagnostic channel into an out-of-memory condition.
549///
550/// ```
551/// use pdfrum_common::{DiagKind, Diagnostics, Severity};
552///
553/// let mut diags = Diagnostics::with_limit(1);
554/// diags.record(Severity::Recovered, DiagKind::XrefRebuilt, None);
555/// diags.record(Severity::Suspicious, DiagKind::MalformedDict, Some(42));
556/// assert_eq!(diags.len(), 1); // second entry counted, not stored
557/// assert_eq!(diags.recorded(), 2);
558/// assert!(diags.dropped() > 0);
559/// ```
560#[derive(Debug, Clone)]
561pub struct Diagnostics {
562 entries: Vec<Diagnostic>,
563 limit: usize,
564 recorded: usize,
565}
566
567impl Diagnostics {
568 /// Default number of diagnostics kept before the sink starts counting
569 /// only. Chosen to survive a pathological file without unbounded growth.
570 pub const DEFAULT_LIMIT: usize = 4096;
571
572 /// An empty sink keeping at most `limit` entries.
573 #[must_use]
574 pub fn with_limit(limit: usize) -> Self {
575 Self {
576 entries: Vec::new(),
577 limit,
578 recorded: 0,
579 }
580 }
581
582 /// Record a recovery. Never fails; past the limit the entry is counted
583 /// but not stored.
584 pub fn record(&mut self, severity: Severity, what: DiagKind, at: Option<u64>) {
585 self.recorded = self.recorded.saturating_add(1);
586 if self.entries.len() < self.limit {
587 self.entries.push(Diagnostic { severity, what, at });
588 }
589 }
590
591 /// Fold another sink's diagnostics into this one, keeping their order.
592 ///
593 /// A call that reads through the stack collects into its own sink; the
594 /// caller that owns the longer-lived record folds it in when the call
595 /// returns. This crate's own limit still applies, so merging a full sink
596 /// into a full one counts the entries without storing them, exactly as
597 /// [`Diagnostics::record`] does.
598 ///
599 /// ```
600 /// use pdfrum_common::{DiagKind, Diagnostics, Severity};
601 ///
602 /// let mut whole = Diagnostics::default();
603 /// let mut part = Diagnostics::default();
604 /// part.record(Severity::Recovered, DiagKind::XrefRebuilt, Some(9));
605 ///
606 /// whole.extend(&part);
607 /// assert!(whole.contains(&DiagKind::XrefRebuilt));
608 /// // `part` is unchanged: this reads it rather than draining it.
609 /// assert_eq!(part.len(), 1);
610 /// ```
611 pub fn extend(&mut self, other: &Diagnostics) {
612 for entry in other.entries() {
613 self.record(entry.severity, entry.what.clone(), entry.at);
614 }
615 }
616
617 /// The stored diagnostics, in the order they were recorded.
618 #[must_use]
619 pub fn entries(&self) -> &[Diagnostic] {
620 &self.entries
621 }
622
623 /// Number of stored diagnostics.
624 #[must_use]
625 pub fn len(&self) -> usize {
626 self.entries.len()
627 }
628
629 /// Whether anything was stored.
630 #[must_use]
631 pub fn is_empty(&self) -> bool {
632 self.entries.is_empty()
633 }
634
635 /// Total number of recoveries recorded, including those dropped past the
636 /// limit.
637 #[must_use]
638 pub fn recorded(&self) -> usize {
639 self.recorded
640 }
641
642 /// How many recoveries were counted but not stored.
643 #[must_use]
644 pub fn dropped(&self) -> usize {
645 self.recorded.saturating_sub(self.entries.len())
646 }
647
648 /// Whether any stored diagnostic matches `what`.
649 #[must_use]
650 pub fn contains(&self, what: &DiagKind) -> bool {
651 self.entries.iter().any(|d| &d.what == what)
652 }
653}
654
655impl Default for Diagnostics {
656 fn default() -> Self {
657 Self::with_limit(Self::DEFAULT_LIMIT)
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::{DiagKind, Diagnostics, Severity};
664
665 #[test]
666 fn records_in_order() {
667 let mut d = Diagnostics::default();
668 d.record(Severity::Recovered, DiagKind::HeaderOffset, Some(7));
669 d.record(Severity::Suspicious, DiagKind::MalformedArray, None);
670 assert_eq!(d.len(), 2);
671 assert_eq!(d.entries()[0].what, DiagKind::HeaderOffset);
672 assert_eq!(d.entries()[0].at, Some(7));
673 assert_eq!(d.entries()[1].severity, Severity::Suspicious);
674 assert!(d.contains(&DiagKind::MalformedArray));
675 assert!(!d.contains(&DiagKind::XrefRebuilt));
676 assert_eq!(d.dropped(), 0);
677 }
678
679 #[test]
680 fn empty_by_default() {
681 let d = Diagnostics::default();
682 assert!(d.is_empty());
683 assert_eq!(d.recorded(), 0);
684 }
685
686 #[test]
687 fn limit_counts_without_storing() {
688 let mut d = Diagnostics::with_limit(0);
689 for _ in 0..5 {
690 d.record(Severity::Recovered, DiagKind::KeywordResync, None);
691 }
692 assert!(d.is_empty());
693 assert_eq!(d.recorded(), 5);
694 assert_eq!(d.dropped(), 5);
695 }
696}