Skip to main content

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}