diff --git a/CHANGELOG.md b/CHANGELOG.md index 2062dec..5641adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). leaf and node limits each end the walk the instant they are reported, so at most one of that pair, plus the depth code's first occurrence, is ever retained in the same walk. Computed lazily, on first access, and cached for the reader's lifetime. (#98) +- **A new option, `PdfReaderOptions.MaxFormXObjectDepth`, and ten new `PdfReaderDiagnosticCode` + values in a `3xx` block reserved for content streams.** The reader now has an internal + content-stream interpreter (ISO 32000-2 §7.8.2), shared machinery the two extraction milestones + still ahead of it (text, then images) will build on; a caller cannot invoke it directly in this + release, so the ten codes below cannot yet be reported to one. `MaxFormXObjectDepth` (default 32, + tighten-only like the three resource limits it joins) is the one part of this a caller sets + today: a ceiling on Form XObject recursion depth (§8.10) the interpreter enforces once something + does call it, reporting `FormXObjectDepthExceeded` and continuing rather than recursing + unboundedly. `Do` on a Form XObject brackets the form's own content in an implicit save and + restore of the graphics state, marked-content nesting, and BX/EX compatibility depth, mirroring + §8.10.1's own steps a) and e), so nothing the form does to any of the three leaks into the page + that invoked it. The rest of the interpreter follows the same policy throughout: a malformed or + unsupported construct is reported, not thrown, and interpretation continues past it. That is + what the other nine codes describe: `ContentStreamLexError`, `UnknownOperator` (`Warning` + severity: an operator this reader skipped is a best-effort reading, not proof the output is + correct), `OperandStackMalformed` for a producer-side malformation (also covering a numeric + literal past this reader's own `long`/`double` range, since what gets dropped there is the + operand itself, not an operator or a push), `ContentLimitExceeded` for this reader's own + processing ceilings instead (an operand-count, array-or-dictionary-operand token, `q`-depth, or + marked-content-depth cap, plus two more for an inline image's dictionary: a value exceeding + the same token cap, or the dictionary itself exceeding 64 key-value pairs), `FormXObjectCycle`, + `FormXObjectBudgetExceeded`, `ResourceMissing`, `InlineImageMalformed`, and + `ContentStreamTooLarge` (one 64 MiB decoded-content budget per page, + shared between its own `/Contents` and every Form XObject it draws; each invocation of a form + counts again, since the interpretation cost this bounds scales with how many times a form is + drawn), charged against the budget as each `/Contents` element or form invocation decodes rather + than only once all of them already have, so a `/Contents` array naming the same oversized stream + many times cannot hold every decode in memory before the cap gets a chance to stop it. `Do` on a + Form XObject also concatenates the form's own `/Matrix` into the graphics state's CTM (§8.10.1 b) + before interpreting its content, so a caller reading the CTM from inside the form's own content + sees the composed value, not the invoker's own CTM with the form's matrix left for it to apply + separately. (#98) ### Changed diff --git a/docs/reader-guide.md b/docs/reader-guide.md index f00764c..58a5777 100644 --- a/docs/reader-guide.md +++ b/docs/reader-guide.md @@ -63,6 +63,7 @@ var options = new PdfReaderOptions MaxDecodedStreamBytes = 64 * 1024 * 1024, ReconstructionBudgetMultiplier = 4, MaxDiagnostics = 200, + MaxFormXObjectDepth = 16, }; using var reader = PdfReader.Open(File.OpenRead("input.pdf"), options); @@ -80,19 +81,24 @@ real `startxref` chain left for `/Prev` to extend, and a recovered trailer's `/I enough to carry into a new revision. Reconstruction also refuses outright the instant it finds any sign the document is encrypted, rather than guessing at a key. -**`MaxDecodedStreamBytes`**, **`ReconstructionBudgetMultiplier`**, and **`MaxDiagnostics`** are all -**tighten-only**. None is a spec requirement — ISO 32000-2 Annex C.1 notes that "a particular PDF -processor running on a particular device and in a particular operating environment will always have -practical limits", and Annex C.3 adds that available memory is "often much less in mobile devices -than desktop computers." The defaults (512 MiB decoded-stream ceiling, an ×8 multiplier on -reconstruction's `max(1 MiB, N × file length)` work budget, a 1000-entry diagnostics cap) are this -library's own choice for a desktop host, not something Annex C mandates. A caller on a more -constrained device, or hardening against a decompression bomb, a file engineered to burn CPU across -many decoy candidates, or a document that would otherwise report the same recoverable condition on -a huge number of objects, can lower any of the three. Raising any of them above its default throws -`ArgumentOutOfRangeException` at `Open` time: nothing above the shipped defaults has been exercised -as a safe ceiling, so these options can only make the reader stricter than it already is, never -looser. +**`MaxDecodedStreamBytes`**, **`ReconstructionBudgetMultiplier`**, **`MaxDiagnostics`**, and +**`MaxFormXObjectDepth`** are all **tighten-only**. None is a spec requirement: ISO 32000-2 Annex +C.1 notes that "a particular PDF processor running on a particular device and in a particular +operating environment will always have practical limits", and Annex C.3 adds that available memory +is "often much less in mobile devices than desktop computers." The defaults (512 MiB decoded-stream +ceiling, an ×8 multiplier on reconstruction's `max(1 MiB, N × file length)` work budget, a +1000-entry diagnostics cap, 32 levels of Form XObject recursion) are this library's own choice for +a desktop host, not something Annex C mandates. A caller on a more constrained device, or hardening +against a decompression bomb, a file engineered to burn CPU across many decoy candidates, a +document that would otherwise report the same recoverable condition on a huge number of objects, or +a page that nests Form XObjects (ISO 32000-2 §8.10) deeper than a caller wants to follow, can lower +any of the four. Raising any of them above its default throws `ArgumentOutOfRangeException` at +`Open` time: nothing above the shipped defaults has been exercised as a safe ceiling, so these +options can only make the reader stricter than it already is, never looser. +`MaxFormXObjectDepth` governs an internal content-stream interpreter this package does not expose +directly yet. Text and image extraction, still ahead on the roadmap below, will be its first +callers. It has no visible effect until then, but validates at `Open` time regardless, alongside +the other three. --- @@ -351,7 +357,7 @@ throws `PdfPasswordException` if the document needs a real one. best-effort: a wrong guess at the object graph during recovery produces a wrong, but internally consistent, decrypted copy. -**`MaxDecodedStreamBytes`, `ReconstructionBudgetMultiplier`, and `MaxDiagnostics` only go down.** +**`MaxDecodedStreamBytes`, `ReconstructionBudgetMultiplier`, `MaxDiagnostics`, and `MaxFormXObjectDepth` only go down.** Raising any of them above its shipped default throws at `Open` time rather than silently clamping — there is no way to ask the reader to trust a file more than its own defaults do. diff --git a/src/VellumPdf.Reader/Content/ContentInterpreter.cs b/src/VellumPdf.Reader/Content/ContentInterpreter.cs new file mode 100644 index 0000000..d28f1fa --- /dev/null +++ b/src/VellumPdf.Reader/Content/ContentInterpreter.cs @@ -0,0 +1,2583 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using System.Buffers.Text; +using VellumPdf.Core; +using VellumPdf.Document; + +namespace VellumPdf.Reader.Content; + +/// +/// An operand-stack content-stream interpreter (ISO 32000-2 §7.8.2): walks a page's /Contents +/// (or a Form XObject's own content, recursively), tracking the graphics and text state, and reports +/// every recognised operator, inline image, and Form XObject boundary to an . +/// Shared machinery for the reader's later text and image extraction (#98): this type positions +/// nothing and resolves no font. It keeps state current and delimits structure. +/// +/// +/// Not thread-safe, and not reentrant across concurrent calls on the same +/// instance, matching every other stateful type in this package. A malformed document degrades +/// (a diagnostic and best-effort recovery) rather than throwing; the sole exception is +/// , which is allowed to propagate: "cannot continue" +/// belongs to the exception channel the rest of this reader already uses it for. +/// +/// Retains at most TWO diagnostics past per +/// (see ), one of each code: +/// fires at most once per run, because +/// the first truncation it reports, whether against the page's own /Contents or a Form +/// XObject's, drives the run's own decoded-bytes budget to zero, and every later stream this run +/// touches then takes the budget-already-spent skip silently instead of truncating (and reporting) +/// again; fires at most once +/// because its own report carries no object number, so the sink's own (code, object, page) dedupe +/// already collapses every recursion past the 4096-invocation ceiling to one entry on its own. +/// +/// +internal sealed class ContentInterpreter +{ + // ISO 32000-2 §7.8.2 gives an operator's own operands no declared bound; this reader's own + // ceiling against a hostile or corrupted stream that never emits an operator at all. 64, not + // 32: Annex C.2 Table C.1 (informative; Annex C.1 is only the annex's own general preamble) + // records 32 as the DeviceN colourant-count limit an earlier PDF version recommended, but + // §8.6.6.5 itself allows "an arbitrary number" of colourants, and Table 73's scn operator + // takes one numeric component per colourant plus an optional trailing pattern name, so a + // legal scn call against a DeviceN space with 32 or more colourants needs 33 or more operands; + // 32 as this reader's own ceiling would reject a legal call, not just a hostile one. + private const int MaxOperandsPerOperator = 64; + + // This reader's own ceiling on how many tokens a single array or dictionary operand may carry, + // counted at every nesting depth (each element, key, value and nested opening delimiter is one + // token), so the count bounds what PdfObjectParser would allocate for the composite as a whole. + // Counting only the top level would let '[[1 1 1 ...]]' hide millions of elements behind one. + // §9.4.3's own TJ array is what most directly exercises it, but CompositeOperandWithinCap + // applies it to every composite operand this interpreter parses, not only TJ's, and enforces it + // BEFORE PdfObjectParser ever materialises the composite (#402 round 3): a 20,000,000-element TJ + // array (about 40 MiB of source text) used to allocate the whole PdfArray, and every boxed + // PdfInteger in it, before this cap was consulted at all, measured at 1,784 MiB allocated and + // 977 MiB committed for one dropped operator and one 309. + private const int MaxCompositeOperandElements = 8192; + + // §8.9.7's Table 91 lists eleven inline image dictionary entries (BitsPerComponent, ColorSpace, + // Decode, DecodeParms, Filter, Height, ImageMask, Intent, Interpolate, Length, Width; Table 92 + // layers abbreviations onto some of their VALUES, not further entries), and §8.9.7 itself says + // "Entries other than those listed shall be ignored", so this reader's ceiling on how many + // key/value pairs one inline image dictionary may carry, before HandleInlineImage gives up on + // it, is generous by construction: a dictionary using only Table 91's keys, full or + // abbreviated, has at most 21 pairs. It exists to bound how much a hostile BI...ID section can + // make this reader allocate one PdfName key (and, per MaxCompositeOperandElements above, one + // capped value) at a time: the check fires on the 65th pair whether or not an ID ever follows + // it (#402 round 7). + private const int MaxInlineImageDictionaryPairs = 64; + + // §8.4.4's q/Q pair; this reader's own ceiling on how deep a legitimate document nests them. + private const int MaxGraphicsStateDepth = 64; + + // §14.6.1's BMC/BDC/EMC nesting; this reader's own ceiling, mirroring MaxGraphicsStateDepth. + private const int MaxMarkedContentDepth = 64; + + // This interpreter's own budget on total successful Form XObject recursions across one page + // (§8.10), independent of PdfReaderOptions.MaxFormXObjectDepth, which bounds nesting DEPTH + // rather than the total COUNT of forms a page may draw. A wide, shallow graph (one page + // invoking the same shallow form thousands of times) is not caught by a depth cap at all. + private const int MaxFormInvocationsPerPage = 4096; + + // This reader's own ceiling on the total decoded content bytes one Run interprets: the page's + // own /Contents (ISO 32000-2 §7.7.3.3 Table 31) and every Form XObject invocation on that page + // (§8.10), combined. Tracked as a running per-Run budget (_contentBytesRemaining) rather than + // checked once against /Contents alone, since a small file drawing one large form many times + // can interpret far more total content than its own /Contents ever declares. + private const long MaxContentBytes = 64L * 1024 * 1024; + + // The bounded resync probe (see ProbeOnce/ClassifyResyncPoint) spends at most this many bytes + // of lexing across an ENTIRE Run, shared by every 'EI' candidate ScanForEi tries: bounding the + // RUN's own total work, not each candidate's, is what keeps ScanForEi's amortised cost linear in + // the content length rather than quadratic (#402 round 3; a per-candidate byte/token cap alone + // still let a probe reject the terminating 'EI' whose own legitimate follow-on token happened to be + // longer than the cap, and a two-window retry to fix THAT still paid, in the worst case, one + // full window's own lexing cost per false candidate: an adversarial run of false candidates + // each immediately followed by an unterminated string, "\" EI (\"" repeated, drove that cost to + // 16.6 s per decoded MiB with no diagnostic to explain it). Once this budget is spent, every + // later candidate in the Run is accepted unverified rather than probed at all (see + // ProbeOutcome.Exhausted): the total probe lexing one Run can ever do is bounded to exactly + // this many bytes, whatever the content size or candidate count (#402 round 4: ProbeOnce's own + // window length is Math.Min(remaining, _probeBytesRemaining), so a window can never itself run + // past what is left of the budget; measured at exactly 16,777,216 charged at the 1, 4, and + // 16 MiB settings alike, not that plus a further window's own worth). + private const long MaxProbeBytesPerRun = 16L * 1024 * 1024; + + private static readonly PdfName XObjectSubtypeForm = new("Form"); + private static readonly PdfName XObjectSubtypeImage = new("Image"); + private static readonly PdfName ImageMaskKey = new("ImageMask"); + private static readonly PdfName WidthKey = new("Width"); + private static readonly PdfName HeightKey = new("Height"); + private static readonly PdfName BitsPerComponentKey = new("BitsPerComponent"); + private static readonly PdfName MatrixKey = new("Matrix"); + private static readonly PdfName BBoxKey = new("BBox"); + private static readonly PdfName FontKey = new("Font"); + + private readonly PdfDocumentReader _reader; + private readonly ReaderLimits _limits; + + // ── Per-Run mutable state: reset at the top of Run, threaded through the recursive descent + // into Form XObjects via StreamContext rather than saved/restored on instance fields. ────────── + private GraphicsState _gs = new(); + private readonly Stack _gsStack = new(); + private readonly TextState _textState = new(); + private readonly List _operands = []; + private bool _operandOverflow; + private int _bxDepth; + private int _markedContentDepth; + private bool _inTextObject; + private readonly HashSet _openForms = []; + private int _formDepth; + private int _formInvocations; + private long _contentBytesRemaining; + private ReadOnlyMemory _currentBuffer; + + // The resync probe's own remaining share of MaxProbeBytesPerRun, and whether it has already + // been spent this Run. Once _probeBudgetExhausted is true, ClassifyResyncPoint accepts every + // later 'EI' candidate without probing it at all (see ProbeOnce's Exhausted outcome), and + // HandleInlineImage reports that against EVERY later inline image this Run delimits, not only + // the one whose own scan spent the budget: the sink's own (code, object, page) dedupe key means + // a later image inside a DIFFERENT content stream (a different Form XObject, or the page's own + // content once a form already spent it) is not deduped against the first report at all, so the + // message names the offset AND the object number of the FIRST occurrence explicitly + // (_probeBudgetExhaustedAtObjectNumber), rather than letting a later report's own ctx attribute + // an offset from a DIFFERENT buffer to itself (#402 round 4). + private long _probeBytesRemaining; + private bool _probeBudgetExhausted; + private int _probeBudgetExhaustedAtOffset; + private int? _probeBudgetExhaustedAtObjectNumber; + + // Pushes PushGraphicsState/PushMarkedContent dropped for being over MaxGraphicsStateDepth or + // MaxMarkedContentDepth: a matching 'Q'/'EMC' consumes one of these before it may report an + // unbalanced pop (see PopGraphicsState/PopMarkedContent), so a producer that legitimately + // nests past this reader's own ceiling and then balances every one of those nests does not + // ALSO get accused of an unbalanced pop purely because this reader declined to push the state + // it was asked to (#402 round 2). Saved and restored across a Form XObject invocation + // (HandleDo) the same way _gsFloor/_markedContentFloor are, so a form's own credit and the + // invoker's own credit can never be consumed across that boundary. + private int _ignoredGsPushes; + private int _ignoredMcPushes; + + // The graphics-state stack depth, marked-content depth, and BX/EX depth a 'Q', 'EMC', or 'EX' + // may not pop or decrement below. All three are 0 for the page's own top-level content and set + // to the invoker's own depth for the duration of one Form XObject's content (see HandleDo): + // ISO 32000-2 §8.10.1 brackets a form's content in an implicit q/Q pair the form's own content + // must not be able to see past, in either direction. + private int _gsFloor; + private int _markedContentFloor; + private int _bxFloor; + + /// The current graphics state, the top of the q/Q stack, readable from + /// inside an callback. Mutated in place; a callback that needs a + /// value after the interpreter moves on must copy it. Reset to a fresh default the moment + /// returns, so a value read after that point is not the last state the run + /// left behind. + internal GraphicsState GraphicsState => _gs; + + /// The current text-positioning state, readable the same way as + /// , and reset on the same schedule: a fresh default once + /// returns. + internal TextState TextState => _textState; + + /// + /// Test-only visibility into how many content streams (the page's own /Contents + /// elements, and Form XObject invocations) this decoded, so a test + /// can pin how early the per-Run content budget stops further decoding without asserting on + /// wall-clock time or process memory (#402 round 2: peak heap scales with how many oversized + /// streams get decoded before the budget is charged, not with the operator count those streams + /// produce). + /// + internal int ContentStreamsDecoded { get; private set; } + + /// + /// Test-only visibility into how many bytes the resync probe (ProbeOnce) has lexed this + /// , so a test can pin how the probe's own MaxProbeBytesPerRun budget + /// bounds its work directly, the same way pins the content + /// budget (#402 round 3). + /// + internal long ProbeBytesConsumed { get; private set; } + + /// Creates an interpreter that resolves resources and streams through + /// , under that reader's own . + internal ContentInterpreter(PdfDocumentReader reader) + { + _reader = reader; + _limits = reader.Limits; + } + + /// + /// Interprets 's content (ISO 32000-2 §7.8.2), reporting every event to + /// and every recoverable condition through this reader's diagnostics + /// channel, scoped per call via . + /// Never throws for a malformed document; is the + /// one exception allowed to propagate (see this type's own remarks). + /// + internal void Run(PdfReadPage page, IContentVisitor visitor) + { + ArgumentNullException.ThrowIfNull(page); + ArgumentNullException.ThrowIfNull(visitor); + + _gs = new GraphicsState(); + _gsStack.Clear(); + _textState.BeginText(); + _operands.Clear(); + _operandOverflow = false; + _bxDepth = 0; + _markedContentDepth = 0; + _inTextObject = false; + _openForms.Clear(); + _formDepth = 0; + _formInvocations = 0; + _contentBytesRemaining = MaxContentBytes; + _gsFloor = 0; + _markedContentFloor = 0; + _bxFloor = 0; + _ignoredGsPushes = 0; + _ignoredMcPushes = 0; + ContentStreamsDecoded = 0; + _probeBytesRemaining = MaxProbeBytesPerRun; + _probeBudgetExhausted = false; + _probeBudgetExhaustedAtOffset = 0; + _probeBudgetExhaustedAtObjectNumber = null; + ProbeBytesConsumed = 0; + + var diagnostics = _reader.CreateContentDiagnosticScope(); + var pageIndex = page.Index; + + try + { + var buffer = BuildPageContentBuffer(page, diagnostics, pageIndex, out var soleObjectNumber); + if (buffer.IsEmpty) + return; + + var ctx = new StreamContext(page.Resources, soleObjectNumber); + InterpretStream(buffer, ctx, visitor, pageIndex, diagnostics); + } + catch (InvalidDataException) + { + // The outermost guard for a malformed indirect-reference chain reached through + // resource, XObject, or Form XObject resolution (a corrupt cross-reference offset, + // say): PdfDocumentReader.Resolve and friends can throw here even though this + // interpreter's own lexer and parser never do (InterpretStream's own catch already + // handles those). Consistent with this type's own class doc promise that + // InvalidDataException never escapes Run, and with the notify-and-continue policy + // every other diagnostic in this channel follows. + // + // The exception's own Message is not forwarded: PdfObjectParser quotes the offending + // header keyword or numeric literal whole, with no bound of its own, and a diagnostic + // is retained for the reader's own lifetime (DiagnosticSink), so an attacker- or + // corruption-sized token would become a comparably sized permanent allocation once per + // (code, object, page) the sink's own dedupe key admits (#402 round 7). + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + "The page's content could not be fully resolved: an object it references could " + + "not be parsed.", + pageIndex: pageIndex); + } + finally + { + // Only _operands, _gsStack and _gs itself can still hold a content-derived reference + // once Run returns: an attacker-sized operand pushed but never consumed (no closing + // operator at all, or one that never overwrites the GraphicsState field holding it) + // must not stay pinned on this interpreter for the rest of its own lifetime, since an + // interpreter reused only after a long delay, or never reused, would otherwise keep the + // LAST Run's content alive regardless of the entry resets above (#402 round 7). + // _openForms holds object numbers only and _operandOverflow is a bool, and both are + // reset on entry like every other value-typed field (_bxDepth, _formDepth, the probe + // budget); they are cleared here as well so the exit state matches the entry state + // rather than because either can pin content. _textState.BeginText() restores the two + // matrices §9.4.1 scopes to a text object for the same symmetry. ProbeBytesConsumed is + // left alone: a test reads it after Run returns as telemetry, not as content-derived + // state. + _operands.Clear(); + _operandOverflow = false; + _gsStack.Clear(); + _gs = new GraphicsState(); + _openForms.Clear(); + _textState.BeginText(); + } + } + + // ── /Contents resolution and concatenation (ISO 32000-2 §7.7.3.3 Table 31) ───────────────────── + + private ReadOnlyMemory BuildPageContentBuffer( + PdfReadPage page, DiagnosticSink diagnostics, int pageIndex, out int? soleObjectNumber) + { + soleObjectNumber = null; + var raw = page.Dictionary.Get(PdfName.Contents); + if (raw is null or PdfNull) + return ReadOnlyMemory.Empty; // Optional; a page with no content draws nothing. + + var chunks = new List(); + var contributingStreams = 0; + int? soleObjectNumberLocal = null; + + // Tracks decoded bytes charged so far in THIS loop, separate from _contentBytesRemaining + // itself (which Concatenate below still consumes its own share of, against the full + // per-Run budget, once the loop finishes): stopping further decodes here is what keeps a + // /Contents array naming the same oversized stream many times from decoding, and holding + // in memory, every one of them before Concatenate ever gets a chance to truncate. Before + // this fix, peak heap scaled with element count x MaxDecodedStreamBytes, since every + // element decoded in full regardless of how far over budget earlier ones already were: one + // 20 MiB-decoding stream referenced 128 times from a 103 KB file measured at 4662 MiB peak + // managed heap while interpreting exactly the same operators as a four-element array would + // (#402 round 2). A single element is still bounded only by MaxDecodedStreamBytes, not by + // this budget: GetDecodedStreamData itself throws before this method ever sees a decode + // larger than that limit. + var decodedSoFar = 0L; + var budgetSpent = false; + + void AddElement(PdfObject element) + { + // Checked ahead of the budgetSpent short-circuit below, unlike the resolve/decode work + // further down: a type check against an object already in hand costs nothing, so a + // /Contents element that is not even an indirect reference gets its own 300 report + // whether or not the budget already ran out on an earlier element, while resolving and + // decoding (costly work, gated on the budget) stay behind the short-circuit (#402 + // round 3; the 300 doc's own "resumes with the next stream" clause covers only the + // resolve-or-decode failure this short-circuit still gates). + if (element is not PdfIndirectReference elementRef) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + "A /Contents element is not an indirect reference to a stream (ISO 32000-2 " + + "§7.7.3.3 Table 31); it was skipped.", + pageIndex: pageIndex); + return; + } + + if (budgetSpent) + return; + + var stream = _reader.ResolveStream(elementRef); + if (stream is null) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + $"/Contents object {elementRef.ObjectNumber} does not resolve to a stream; it " + + "was skipped.", + elementRef.ObjectNumber, pageIndex: pageIndex); + return; + } + + byte[]? decoded; + try + { + decoded = _reader.GetDecodedStreamData(stream); + } + catch (InvalidDataException) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + $"Content stream object {stream.ObjectNumber} failed to decode; it was skipped.", + stream.ObjectNumber, pageIndex: pageIndex); + return; + } + + if (decoded is null) + { + // An image filter (DCTDecode, JPXDecode, ...) in the chain is never valid on a + // content stream, which must decode to PDF operator syntax. + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + $"Content stream object {stream.ObjectNumber} carries an image filter and " + + "cannot be decoded as content; it was skipped.", + stream.ObjectNumber, pageIndex: pageIndex); + return; + } + + ContentStreamsDecoded++; + chunks.Add(decoded); + contributingStreams++; + soleObjectNumberLocal = contributingStreams == 1 ? stream.ObjectNumber : null; + + decodedSoFar += decoded.Length + 1L; // the separator byte Concatenate also charges below + if (decodedSoFar > _contentBytesRemaining) + { + budgetSpent = true; + diagnostics.ReportRetained( + PdfReaderDiagnosticCode.ContentStreamTooLarge, + $"The page's /Contents exceeded the {MaxContentBytes / (1024 * 1024)} MiB " + + "decoded-size cap shared with every Form XObject it draws; interpretation " + + "stopped there.", + pageIndex: pageIndex); + } + } + + if (raw is PdfArray directArray) + { + foreach (var element in Enumerate(directArray)) + AddElement(element); + } + else + { + // A single reference: could name a stream directly, or (nonconformant but tolerated) + // an array object. + var resolved = _reader.ResolveValue(raw); + if (resolved is PdfArray arr) + { + foreach (var element in Enumerate(arr)) + AddElement(element); + } + else + { + AddElement(raw); + } + } + + soleObjectNumber = soleObjectNumberLocal; + var buffer = Concatenate(chunks, diagnostics, pageIndex, _contentBytesRemaining, out var truncated); + // A truncation here already spent the whole per-Run budget (see Concatenate's own remarks + // on why it always reports at most once): forcing the remainder to exactly zero, rather + // than the few leftover bytes the whitespace-boundary back-off may have left unused, is + // what keeps a later Form XObject from triggering a SECOND ContentStreamTooLarge report + // against a different object number once this run is already over budget. + _contentBytesRemaining = truncated ? 0 : _contentBytesRemaining - buffer.Length; + return buffer; + } + + private static IEnumerable Enumerate(PdfArray array) + { + for (var i = 0; i < array.Count; i++) + yield return array[i]; + } + + // Joins each stream's decoded bytes with a single '\n' between them, per ISO 32000-2 §7.7.3.3 + // Table 31's own text: "the division between streams may occur only at the boundaries between + // lexical tokens". So a token split across two streams (e.g. one ending "BT" and the next + // starting immediately with "ET") is not glued into one token by naive concatenation. Enforces + // budget (this Run's own remaining share of MaxContentBytes) across the total, truncating at + // the last whitespace boundary within budget rather than mid-token, so what IS interpreted is a + // clean prefix rather than one broken by an artificial cut. Reports and sets + // truncated = true at most once: the caller (BuildPageContentBuffer, or HandleDo for a Form + // XObject's own decoded content) is responsible for driving the run's remaining budget to zero + // once this returns true, so a later stream never re-triggers the report. + private static ReadOnlyMemory Concatenate( + List chunks, DiagnosticSink diagnostics, int pageIndex, long budget, out bool truncated) + { + truncated = false; + if (chunks.Count == 0) + return ReadOnlyMemory.Empty; + + long total = 0; + foreach (var chunk in chunks) + total += chunk.Length + 1; // +1 for the separator this method inserts after each chunk + + if (total <= budget) + { + var buffer = new byte[total]; + var pos = 0; + foreach (var chunk in chunks) + { + chunk.CopyTo(buffer, pos); + pos += chunk.Length; + buffer[pos++] = (byte)'\n'; + } + return buffer; + } + + // Over budget: copy whole chunks while they fit, then take as much of the chunk that would + // overflow as fits, backing off to the nearest preceding whitespace byte so the cut falls on + // a token boundary rather than through the middle of one. + var cappedLength = (int)Math.Min(budget, int.MaxValue); + var capped = new byte[cappedLength]; + var written = 0; + foreach (var chunk in chunks) + { + var remaining = cappedLength - written; + if (remaining <= 0) + break; + + if (chunk.Length + 1 <= remaining) + { + chunk.CopyTo(capped, written); + written += chunk.Length; + capped[written++] = (byte)'\n'; + continue; + } + + var take = TruncateAtWhitespaceBoundary(chunk, Math.Min(chunk.Length, remaining)); + Array.Copy(chunk, 0, capped, written, take); + written += take; + break; + } + + truncated = true; + diagnostics.ReportRetained( + PdfReaderDiagnosticCode.ContentStreamTooLarge, + $"The page's /Contents exceeded the {MaxContentBytes / (1024 * 1024)} MiB decoded-size " + + "cap shared with every Form XObject it draws; interpretation stopped there.", + pageIndex: pageIndex); + + return new ReadOnlyMemory(capped, 0, written); + } + + // Backs a byte-budget cut off to the nearest preceding whitespace byte, so neither Concatenate + // (across a /Contents array) nor HandleDo (a single Form XObject's own decoded content) ever + // cuts a token in half when the per-Run content budget runs out mid-chunk. + private static int TruncateAtWhitespaceBoundary(ReadOnlySpan chunk, int take) + { + while (take > 0 && !PdfLexer.IsWhitespaceByte(chunk[take - 1])) + take--; + return take; + } + + // ── Main interpretation loop ───────────────────────────────────────────────────────────────── + + /// Resources and diagnostic-attribution identity for one content stream being + /// interpreted: the page's own for the top-level call, a Form XObject's own (falling back to + /// its invoker's per §8.10.2) for a recursive one. + private readonly record struct StreamContext(PdfDictionary? Resources, int? DiagObjectNumber); + + private void InterpretStream( + ReadOnlyMemory data, StreamContext ctx, IContentVisitor visitor, int pageIndex, + DiagnosticSink diagnostics) + { + var outerBuffer = _currentBuffer; + _currentBuffer = data; + try + { + var lexer = new PdfLexer(data, contentStreamMode: true); + var parser = new PdfObjectParser(lexer); + + try + { + while (true) + { + lexer.SkipWhitespaceAndComments(); + if (lexer.AtEnd) + break; + + var offset = lexer.Position; + var token = lexer.NextToken(); + if (token.Kind == TokenKind.EndOfInput) + break; + + switch (token.Kind) + { + case TokenKind.Integer or TokenKind.Real: + HandleNumber(token, ctx, diagnostics, pageIndex); + break; + + case TokenKind.LiteralString: + PushOperand( + PdfObjectParser.DecodeLiteralString(token.Raw), ctx, diagnostics, + pageIndex); + break; + + case TokenKind.HexString: + PushOperand( + PdfObjectParser.DecodeHexString(token.Raw), ctx, diagnostics, + pageIndex); + break; + + case TokenKind.Name: + PushOperand(PdfObjectParser.ParseName(token), ctx, diagnostics, pageIndex); + break; + + case TokenKind.ArrayBegin or TokenKind.DictBegin: + // Pre-scanned with the lexer alone (no PdfObject allocation) before + // ParseObject ever materialises it: an adversarial TJ array can name + // millions of elements in a source text small enough to decode well + // within the content budget, so the token-count cap has to be + // consulted before the allocation it exists to bound, not after (#402 + // round 3; see CompositeOperandWithinCap and MaxCompositeOperandElements). + if (CompositeOperandWithinCap(lexer, out var countPassLexerFailed)) + { + lexer.Seek(offset); + PushOperand(parser.ParseObject(), ctx, diagnostics, pageIndex); + } + else + { + _operandOverflow = true; + var shape = token.Kind == TokenKind.ArrayBegin ? "An array" : "A dictionary"; + diagnostics.Report( + PdfReaderDiagnosticCode.ContentLimitExceeded, + $"{shape} operand exceeds {MaxCompositeOperandElements} tokens; " + + "the operator taking it was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + if (countPassLexerFailed) + { + // The count pass itself hit a malformed byte inside the + // composite (an unterminated string, say) before it ever + // reached the cap-comparison this branch reports 309 for; that + // failure did not merely bail the count pass out early on count + // alone, so it gets its own 300 too (#402 round 4: an + // over-cap composite whose count pass failed this way used to + // report only the 309, silently ending interpretation of the + // rest of the stream with nothing to explain why, where the + // identical failure on an UNDER-cap composite already reported + // 300 through ParseObject's own re-parse). + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + "The content stream's syntax could not be interpreted past " + + "this point; interpretation of it stopped here.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + } + } + break; + + case TokenKind.Keyword: + { + var raw = token.Raw.Span; + if (raw.SequenceEqual("true"u8)) + PushOperand(PdfBoolean.True, ctx, diagnostics, pageIndex); + else if (raw.SequenceEqual("false"u8)) + PushOperand(PdfBoolean.False, ctx, diagnostics, pageIndex); + else if (raw.SequenceEqual("null"u8)) + PushOperand(PdfNull.Instance, ctx, diagnostics, pageIndex); + else if (raw.SequenceEqual("BI"u8)) + { + if (!HandleInlineImage(lexer, parser, ctx, visitor, diagnostics, pageIndex, offset)) + goto endOfStream; + } + else + { + // Decoded only far enough to name the operator or, for an + // unrecognised one, excerpt it in the 301 below (DiagnosticExcerpt.Quote + // truncates past DiagnosticExcerpt.MaxChars anyway); ReadKeyword puts + // no bound on a keyword's own length, so materialising the + // whole thing here for an attacker-sized token would allocate + // what the diagnostic then discards most of (#402 round 6). + // HandleOperator's own dispatch below goes through + // ContentOperators.IsKnown(string), a bare dictionary lookup + // with no length guard of its own (only the ReadOnlySpan + // overload the resync probe uses, in ContentOperators.cs, bails + // out past 8 bytes); a keyword truncated to DiagnosticExcerpt.MaxChars + // + 1 bytes still fails that lookup exactly the way the whole + // one did, since no key in the table is longer than 3 characters, + // and every recognised operator is decoded in full either way. + var decodeLength = Math.Min(raw.Length, DiagnosticExcerpt.MaxChars + 1); + var name = System.Text.Encoding.Latin1.GetString(raw[..decodeLength]); + HandleOperator( + name, raw.Length, offset, ctx, visitor, diagnostics, pageIndex); + } + break; + } + + default: + throw new InvalidDataException( + $"Unexpected token {token.Kind} at content-stream offset {offset}."); + } + } + endOfStream:; + } + catch (InvalidDataException) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + "The content stream's syntax could not be interpreted past this point; " + + "interpretation of it stopped here.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + } + } + finally + { + _currentBuffer = outerBuffer; + } + } + + // ── Operand collection ─────────────────────────────────────────────────────────────────────── + + private void HandleNumber(Token token, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (TryParseOperandNumber(token.Raw.Span, token.Kind == TokenKind.Real, out var value)) + { + PushOperand(value!, ctx, diagnostics, pageIndex); + return; + } + + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + "A numeric operand did not parse, or was not finite; it was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + } + + // System.Buffers.Text.Utf8Parser backs this, but against a normalised copy of the token's bytes, + // not the raw span, because PDF's own numeric grammar (ISO 32000-2 §7.3.3) allows a bare leading + // or trailing decimal point ("-.5", "6.") that the BCL's own double formats do not universally + // accept the same way across runtimes; padding a missing digit on either side of '.' sidesteps + // that without reimplementing number parsing. + private static bool TryParseOperandNumber(ReadOnlySpan raw, bool isReal, out PdfObject? result) + { + result = null; + if (raw.IsEmpty) + return false; + + var negative = false; + var span = raw; + if (span[0] is (byte)'+' or (byte)'-') + { + negative = span[0] == (byte)'-'; + span = span[1..]; + } + + // §7.3.3 allows "an optional sign" (singular). A second sign character immediately after + // the first ("--5", "-+5") is not this grammar's syntax; rejecting it here, rather than + // letting the digit scan below fail on it less directly, keeps the failure attributable to + // the actual malformation instead of a coincidentally-empty digit run. + if (!span.IsEmpty && span[0] is (byte)'+' or (byte)'-') + return false; + + if (!isReal) + { + if (span.IsEmpty || !Utf8Parser.TryParse(span, out long value, out var consumed) || consumed != span.Length) + return false; + result = new PdfInteger(negative ? -value : value); + return true; + } + + // The token length is attacker-controlled, so only stackalloc for short literals; an + // operand of about 1.5 million digits or more would otherwise overflow the stack (an + // uncatchable crash); one million digits alone still returns normally. + var paddedLength = span.Length + 2; + Span padded = paddedLength <= 1024 ? stackalloc byte[paddedLength] : new byte[paddedLength]; + var len = 0; + if (span.IsEmpty || span[0] == (byte)'.') + padded[len++] = (byte)'0'; + span.CopyTo(padded[len..]); + len += span.Length; + if (len == 0 || padded[len - 1] == (byte)'.') + padded[len++] = (byte)'0'; + + if (!Utf8Parser.TryParse(padded[..len], out double d, out var consumedReal) || consumedReal != len) + return false; + if (negative) + d = -d; + if (!double.IsFinite(d)) + return false; + + result = new PdfReal(d); + return true; + } + + private void PushOperand(PdfObject value, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_operandOverflow) + return; + + if (_operands.Count >= MaxOperandsPerOperator) + { + _operandOverflow = true; + diagnostics.Report( + PdfReaderDiagnosticCode.ContentLimitExceeded, + $"More than {MaxOperandsPerOperator} operands accumulated before an operator; the " + + "next operator was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + + _operands.Add(value); + } + + private void ClearOperands() + { + _operands.Clear(); + _operandOverflow = false; + } + + // Pre-scans an array or dictionary operand with the LEXER ALONE, starting right after its + // already-consumed opening token, to decide whether it stays within + // MaxCompositeOperandElements before PdfObjectParser ever allocates a PdfObject for it. Every + // token inside the composite counts, at any depth, because every one of them becomes an + // allocation once materialised; closing delimiters are the exception, since they allocate + // nothing. When the composite terminates cleanly, this leaves the lexer positioned right after + // the matching close, so the caller can either seek back to re-parse it (within cap) or move on + // to the next token (over cap: nothing more from this composite is needed); an + // unterminated composite (see lexerFailed below) leaves the lexer wherever the failed token + // left it instead, which is NOT necessarily right after any close (#402 round 4: qualifying + // this to the terminated case, since the unterminated one two paragraphs below already + // contradicted an unqualified claim here). An unterminated composite is judged by the same + // count: within the cap, the caller's ParseObject re-derives the failure and reports it + // (ContentStreamLexError, 300) the way it always has; over the cap, lexerFailed tells the + // caller to report that same 300 directly, since nothing will re-parse this composite to + // derive it the way the within-cap path does (#402 round 4: over the cap used to report only + // ContentLimitExceeded, silently dropping the fact that the composite was ALSO malformed and + // not merely oversized). + private static bool CompositeOperandWithinCap(PdfLexer lexer, out bool lexerFailed) + { + var depth = 1; + var count = 0; + lexerFailed = false; + while (depth > 0) + { + Token token; + try + { + token = lexer.NextToken(); + } + catch (InvalidDataException) + { + lexerFailed = true; + break; + } + + if (token.Kind == TokenKind.EndOfInput) + break; + + switch (token.Kind) + { + case TokenKind.ArrayBegin or TokenKind.DictBegin: + count++; + depth++; + break; + + case TokenKind.ArrayEnd or TokenKind.DictEnd: + depth--; + break; + + default: + count++; + break; + } + } + return count <= MaxCompositeOperandElements; + } + + // ── Operator dispatch ──────────────────────────────────────────────────────────────────────── + + private void HandleOperator( + string name, int keywordByteLength, int offset, StreamContext ctx, IContentVisitor visitor, + DiagnosticSink diagnostics, int pageIndex) + { + if (!ContentOperators.IsKnown(name)) + { + // Reported only outside a BX/EX compatibility section. Inside one, Table 33 is + // explicit: "Unrecognised operators (along with their operands) shall be ignored + // without error until the balancing EX operator is encountered." Outside one, §7.8.2 + // says "an error shall occur"; this reader instead notifies and continues, the same + // notify-and-continue choice every other diagnostic in this channel makes. The sink's + // dedupe key is (code, object, page), so only the first unknown name on a page is + // recorded; a second distinct name on the same page is dropped by the sink. + // + // Either way, the operand stack is cleared: §7.8.2 "operands shall not be left over + // when an operator finishes execution" applies to an unrecognised keyword's own + // (no-op) execution just as much as to a recognised one (#402 round 2; an earlier + // version kept the operands outside BX/EX on the theory that a stray "R" left over + // from indirect-reference syntax §7.8.2 forbids in content streams usually belonged to + // whatever OPERATOR followed rather than to "R" itself, but that leniency broke a + // differently-shaped input just as easily: '10 20 Zork' ahead of '1 w' silently fed + // the leftover 20 into 'w' as its own operand instead of 1). + if (_bxDepth <= 0) + { + diagnostics.Report( + PdfReaderDiagnosticCode.UnknownOperator, + $"'{DiagnosticExcerpt.Quote(name, keywordByteLength)}' is not one of the operators ISO " + + "32000-2 Annex A Table A.1 defines; it was ignored.", + pageIndex: pageIndex); + } + ClearOperands(); + return; + } + + // BX/EX's own arity (0) is checked the same way as every other operator's below, unlike the + // dispatch this used to short-circuit through before any check ran at all: '1 2 3 BX 4 EX' + // used to hand the visitor BX with 3 leftover operands and EX with 1, reporting nothing even + // though _arity["BX"] is 0 (#402 round 3). A mismatch here still only drops the OPERANDS, + // not the operator itself: Table 33 opens or closes a compatibility section regardless of + // what garbage operands preceded BX/EX, so the section transition below still runs either + // way, unlike an ordinary operator's mismatch, which drops the whole call. + var expected = ContentOperators.ExpectedOperandCount(name); + var arityOk = true; + if (_operandOverflow) + { + arityOk = false; + // A 'q', 'BMC', or 'BDC' dropped here for one of this reader's own ceilings (an + // over-cap composite operand, or the 64-operand-per-operator cap in PushOperand) is + // still credited toward its own matching pop, the same as an over-DEPTH push already + // is in PushGraphicsState/PushMarkedContent: §14.6.2 puts no size bound on a property + // list, so a producer whose BDC's own dictionary operand happens to exceed this + // reader's own MaxCompositeOperandElements, and who then balances that BDC with an + // EMC, must not ALSO be accused of an unbalanced EMC purely because this reader + // declined to push the marked-content nesting it was asked to (#402 round 4). An + // arity-mismatch drop (e.g. '1 q') is producer-side, not this reader's own ceiling, so + // it keeps reporting through the branch below instead. + switch (name) + { + case "q": + _ignoredGsPushes++; + break; + case "BMC" or "BDC": + _ignoredMcPushes++; + break; + } + ClearOperands(); // Already reported when the overflow itself happened. + } + else if (expected != ContentOperators.Variable && _operands.Count != expected) + { + arityOk = false; + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + $"'{name}' expects {expected} operand(s) but {_operands.Count} were on the stack; " + + "it was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + ClearOperands(); + } + + if (name is "BX") + { + _bxDepth++; + EmitAndClear(name, offset, visitor); + return; + } + if (name is "EX") + { + if (_bxDepth > _bxFloor) + _bxDepth--; + EmitAndClear(name, offset, visitor); + return; + } + + if (!arityOk) + return; + + // ISO 32000-2 §7.8.2: "Dictionaries shall be permitted as operands only by certain specific + // operators". BDC and DP (§14.6.2) are the only two in Annex A Table A.1 that take one. + if (name is not ("BDC" or "DP")) + { + foreach (var operand in _operands) + { + if (operand is not PdfDictionary) + continue; + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + $"'{name}' does not accept a dictionary operand (ISO 32000-2 §7.8.2); it was " + + "dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + ClearOperands(); + return; + } + } + + // A numeric, name, or string operand this interpreter reads for its OWN state, or reads to + // look up a resource on its own behalf (cm/Tf/Td/'/"/gs/cs/CS/sh/Do/etc. below), used to be + // handed to NumberOperand or a bare pattern match unchecked: a wrongly typed operand at the + // right arity silently substituted 0, silently no-oped a resource lookup, or (for Do) + // silently dropped the invocation, with no diagnostic at all (#402 round 3; the resource- + // lookup operators gs/cs/CS/sh joined this check in round 4, since ValidateNamedResource and + // ValidateColorSpaceResource already read _operands[0] as a name and silently no-op on a + // wrong type otherwise). An operator this interpreter only forwards to the visitor untouched + // (w, J, the colour-setting operators, ...) is exempt: its own operand types are the + // visitor's to type-check, not this interpreter's, since this interpreter never reads them + // for its own state or its own resource lookups. + if (!ValidateOperandTypes(name, ctx, diagnostics, pageIndex)) + return; + + switch (name) + { + case "TJ": + if (_operands[0] is not PdfArray) + { + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + "TJ's operand is not an array; it was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + ClearOperands(); + return; + } + break; + + case "q": + PushGraphicsState(ctx, diagnostics, pageIndex); + break; + + case "Q": + PopGraphicsState(ctx, diagnostics, pageIndex); + break; + + case "cm": + _gs.Ctm = new Matrix( + NumberOperand(0), NumberOperand(1), NumberOperand(2), NumberOperand(3), + NumberOperand(4), NumberOperand(5)).Concat(_gs.Ctm); + break; + + case "BT": + _textState.BeginText(); + _inTextObject = true; + break; + + case "ET": + _inTextObject = false; + break; + + case "Tc": + _gs.CharSpacing = NumberOperand(0); + break; + + case "Tw": + _gs.WordSpacing = NumberOperand(0); + break; + + case "Tz": + _gs.HorizontalScaling = NumberOperand(0); + break; + + case "TL": + _gs.Leading = NumberOperand(0); + break; + + case "Tf": + ValidateFontResource(ctx, diagnostics, pageIndex); + _gs.Font = _operands[0]; + _gs.FontSize = NumberOperand(1); + break; + + case "Tr": + _gs.RenderMode = (int)NumberOperand(0); + break; + + case "Ts": + _gs.Rise = NumberOperand(0); + break; + + case "Td": + _textState.MoveTextPosition(NumberOperand(0), NumberOperand(1)); + break; + + case "TD": + { + var ty = NumberOperand(1); + _gs.Leading = -ty; + _textState.MoveTextPosition(NumberOperand(0), ty); + break; + } + + case "Tm": + _textState.SetTextMatrix(new Matrix( + NumberOperand(0), NumberOperand(1), NumberOperand(2), NumberOperand(3), + NumberOperand(4), NumberOperand(5))); + break; + + case "T*": + _textState.MoveTextPosition(0, -_gs.Leading); + break; + + case "'": + // Table 107: "This operator shall have the same effect as the code T* string Tj". + // T*'s own move (§9.4.3) runs here; the text-showing half stays the visitor's, the + // same as Tj's own string operand, which this interpreter never reads (#402 round 4). + _textState.MoveTextPosition(0, -_gs.Leading); + break; + + case "\"": + // Table 107: "This operator shall have the same effect as this code: aw Tw ac Tc + // string '". aw and ac land in the text state before the T*-equivalent move that + // "'" itself performs (#402 round 4). + _gs.WordSpacing = NumberOperand(0); + _gs.CharSpacing = NumberOperand(1); + _textState.MoveTextPosition(0, -_gs.Leading); + break; + + case "BDC" or "BMC": + PushMarkedContent(ctx, diagnostics, pageIndex); + break; + + case "EMC": + PopMarkedContent(ctx, diagnostics, pageIndex); + break; + + case "cs" or "CS": + ValidateColorSpaceResource(name, ctx, diagnostics, pageIndex); + break; + + case "sh": + ValidateNamedResource(name, ShadingKey, ctx, diagnostics, pageIndex); + break; + + case "gs": + HandleExtGState(ctx, diagnostics, pageIndex); + break; + + case "Do": + HandleDo(offset, ctx, visitor, diagnostics, pageIndex); + return; // HandleDo emits "Do" itself before recursing. + + default: + break; // Recognised but state-inert: path, colour, clipping, rendering operators. + } + + EmitAndClear(name, offset, visitor); + } + + private void EmitAndClear(string name, int offset, IContentVisitor visitor) + { + visitor.OnOperator(name, _operands, offset); + ClearOperands(); + } + + private double NumberOperand(int index) => _operands[index] switch + { + PdfInteger i => i.Value, + PdfReal r => r.Value, + _ => 0, + }; + + private static bool IsNumericOperand(PdfObject obj) => obj is PdfInteger or PdfReal; + + // Type-checks the operands of every operator this interpreter reads for its own graphics or + // text state, or for its own resource lookup, ahead of the switch below (or, for gs/cs/CS/sh, + // the resource-lookup helpers) that read them: by the time this runs, the arity check above + // already guarantees _operands.Count matches each name's own Table A.1 arity, so only the TYPE + // of each operand is in question here. A mismatch reports OperandStackMalformed (the same code + // the 302 doc already covers "an operand of the wrong type where the arity is otherwise right" + // under) and drops the operator entirely, the way the dictionary-operand check just above does, + // rather than letting NumberOperand's own 0-default or a silent Do/gs/cs/CS/sh no-op mask the + // malformation (#402 round 3; gs/cs/CS/sh joined this method in round 4). + private bool ValidateOperandTypes(string name, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + bool ok; + switch (name) + { + case "cm" or "Tm": + ok = IsNumericOperand(_operands[0]) && IsNumericOperand(_operands[1]) + && IsNumericOperand(_operands[2]) && IsNumericOperand(_operands[3]) + && IsNumericOperand(_operands[4]) && IsNumericOperand(_operands[5]); + break; + + case "Tc" or "Tw" or "Tz" or "TL" or "Tr" or "Ts": + ok = IsNumericOperand(_operands[0]); + break; + + case "Td" or "TD": + ok = IsNumericOperand(_operands[0]) && IsNumericOperand(_operands[1]); + break; + + case "Tf": + ok = _operands[0] is PdfName && IsNumericOperand(_operands[1]); + break; + + case "Do": + ok = _operands[0] is PdfName; + break; + + case "'": + ok = _operands[0] is PdfLiteralString or PdfHexString; + break; + + case "\"": + ok = IsNumericOperand(_operands[0]) && IsNumericOperand(_operands[1]) + && _operands[2] is PdfLiteralString or PdfHexString; + break; + + // Table 73 (§8.6.8): CS/cs's own operand is "name". Table 56 (§8.4.4): gs's own operand + // is "dictName". Table 76 (§8.7.4.2): sh's own operand is "name". All three are read for + // a resource lookup below (ValidateColorSpaceResource/HandleExtGState/ValidateNamedResource), + // the same reason Do's own name operand is checked here rather than left to the visitor. + case "cs" or "CS" or "gs" or "sh": + ok = _operands[0] is PdfName; + break; + + default: + return true; // Forwarded to the visitor untouched; not this interpreter's to check. + } + + if (ok) + return true; + + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + $"'{name}' operand is the wrong type for the arity ISO 32000-2 Annex A Table A.1 gives " + + "it; it was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + ClearOperands(); + return false; + } + + // ── q/Q, BMC/BDC/EMC ───────────────────────────────────────────────────────────────────────── + + private void PushGraphicsState(StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_gsStack.Count >= MaxGraphicsStateDepth) + { + // The push itself is dropped, but the 'q' it belongs to is still credited toward a + // LATER 'Q': nothing in §7.8.2 or §8.4.4 bounds how deep a legitimate document nests + // 'q'/'Q', so a producer that nests past this reader's own ceiling and then balances + // every one of those nests must not ALSO be accused of an unbalanced 'Q' purely + // because this reader declined to push the state it was asked to (#402 round 2; without + // this credit, 65 balanced 'q'...'Q' pairs reported ContentLimitExceeded AND + // OperandStackMalformed together, and desynchronised GraphicsState.Ctm from the actual + // nesting besides). + _ignoredGsPushes++; + diagnostics.Report( + PdfReaderDiagnosticCode.ContentLimitExceeded, + $"The graphics-state stack exceeded {MaxGraphicsStateDepth} nested 'q' saves; " + + "further saves were ignored.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + _gsStack.Push(_gs); + _gs = _gs.Clone(); + } + + private void PopGraphicsState(StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_gsStack.Count <= _gsFloor) + { + // A pop with nothing left on the stack to restore first spends a credit from an earlier + // over-cap push (see PushGraphicsState) before it may report an unbalanced 'Q': only + // once that credit is exhausted is this the "restore with nothing to restore" problem + // the report below describes (#402 round 2). + if (_ignoredGsPushes > 0) + { + _ignoredGsPushes--; + return; + } + + // An unbalanced 'q' still open at end of stream is fine (nothing downstream needs the + // state restored past the last operator this interpreter saw); an unbalanced 'Q' is the + // opposite problem (a restore with nothing to restore), so this one is reported. Inside + // a Form XObject's own content, _gsFloor is that form's own entry depth (see HandleDo), + // so a form's own 'Q' can pop no further than where the form started, and a 'Q' the + // page itself already owns is never available for the form's content to pop. + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + "'Q' with no matching 'q' on the graphics-state stack; it was ignored.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + _gs = _gsStack.Pop(); + } + + private void PushMarkedContent(StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_markedContentDepth >= MaxMarkedContentDepth) + { + // See PushGraphicsState's own remarks: the same over-cap-push credit, tracked + // separately here since marked-content nesting and the graphics-state stack are + // independent depths. + _ignoredMcPushes++; + diagnostics.Report( + PdfReaderDiagnosticCode.ContentLimitExceeded, + $"Marked-content nesting exceeded {MaxMarkedContentDepth} levels; further " + + "'BMC'/'BDC' operators were ignored.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + _markedContentDepth++; + } + + private void PopMarkedContent(StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_markedContentDepth <= _markedContentFloor) + { + // See PopGraphicsState's own remarks: spend an over-cap push credit before reporting. + if (_ignoredMcPushes > 0) + { + _ignoredMcPushes--; + return; + } + + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + "'EMC' with no matching 'BMC'/'BDC'; it was ignored.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + _markedContentDepth--; + } + + // ── Resource lookups (ISO 32000-2 §7.8.3) ─────────────────────────────────────────────────── + + private static readonly PdfName ShadingKey = PdfName.Shading; + + private static readonly HashSet _standaloneColorSpaceNames = + new(StringComparer.Ordinal) { "DeviceGray", "DeviceRGB", "DeviceCMYK", "Pattern" }; + + private void ValidateFontResource(StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) => + ValidateNamedResource("Tf", PdfName.Font, ctx, diagnostics, pageIndex); + + private void ValidateColorSpaceResource( + string op, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_operands[0] is not PdfName csName || _standaloneColorSpaceNames.Contains(csName.Value)) + return; // §8.6.3: the four device/pattern spaces are never resource-dictionary entries. + ValidateNamedResource(op, PdfName.ColorSpace, ctx, diagnostics, pageIndex); + } + + private void ValidateNamedResource( + string op, PdfName category, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_operands.Count == 0 || _operands[0] is not PdfName name) + return; + + if (ctx.Resources is not null && TryGetResource(ctx.Resources, category, name, out _)) + return; + + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"'{op}' names '/{DiagnosticExcerpt.Quote(name.Value)}', absent from the applicable /Resources " + + $"/{category.Value} dictionary.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + } + + private bool TryGetResource(PdfDictionary resources, PdfName category, PdfName name, out PdfObject value) + { + value = PdfNull.Instance; + var categoryRaw = resources.Get(category); + if (categoryRaw is null) + return false; + if (_reader.ResolveValue(categoryRaw) is not PdfDictionary categoryDict) + return false; + var raw = categoryDict.Get(name); + if (raw is null or PdfNull) + return false; + value = raw; + return true; + } + + // ── gs (ISO 32000-2 §8.4.5 Table 57) ──────────────────────────────────────────────────────── + + private void HandleExtGState(StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (_operands[0] is not PdfName gsName) + return; + + if (ctx.Resources is null || !TryGetResource(ctx.Resources, PdfName.ExtGState, gsName, out var raw)) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"'gs' names '/{DiagnosticExcerpt.Quote(gsName.Value)}', absent from the applicable /Resources " + + "/ExtGState dictionary.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + + if (_reader.ResolveValue(raw) is not PdfDictionary extGState) + return; + + // Table 57: /Font is "an array of the form [font size] where font shall be an indirect + // reference to a font dictionary". §7.3.10 lets any dictionary entry be given as an + // indirect reference, not only the ones a table says must be one, so /Font's own value is + // resolved before the shape check the same way a form XObject's /Matrix and /BBox are. + // Every other ExtGState entry is out of scope for this interpreter: it neither positions + // text nor renders colour or transparency. + if (extGState.Get(FontKey) is { } fontRaw && _reader.ResolveValue(fontRaw) is PdfArray fontArray + && fontArray.Count == 2) + { + _gs.Font = fontArray[0]; + _gs.FontSize = ReadNumber(fontArray[1]); + } + } + + private static double ReadNumber(PdfObject obj) => obj switch + { + PdfInteger i => i.Value, + PdfReal r => r.Value, + _ => 0, + }; + + // ── Do / Form XObjects (ISO 32000-2 §8.10) ────────────────────────────────────────────────── + + private void HandleDo( + int offset, StreamContext ctx, IContentVisitor visitor, DiagnosticSink diagnostics, int pageIndex) + { + var xobjectNameOperand = _operands.Count == 1 ? _operands[0] : null; + EmitAndClear("Do", offset, visitor); + + if (_inTextObject) + { + // §8.2 Figure 9 admits only the general graphics state, colour, text state, + // text-positioning, text-showing, marked-content, and compatibility categories of + // Table 50 inside a text object (seven, not six: #402 round 3); 'Do' sits in the + // XObjects category, so a producer that invokes it there is wrong regardless of what + // the named XObject turns out to be. The recursion below still runs (a text-object + // violation is not itself a reason to skip an otherwise-resolvable Form), but the + // shared _textState instance a form's own content may disturb (BT/Td/ET, unbracketed by + // any q/Q-style save) is saved and restored around that recursion below regardless of + // this check, so the report here is purely informational: nothing downstream depends on + // _inTextObject to avoid the leak (#402 round 2). + diagnostics.Report( + PdfReaderDiagnosticCode.OperandStackMalformed, + "'Do' occurred inside a text object (ISO 32000-2 §8.2 Figure 9 admits no XObjects " + + "category operator there).", + ctx.DiagObjectNumber, pageIndex: pageIndex); + } + + // Defensive only: ValidateOperandTypes (~:839) already guarantees a one-element, PdfName + // operand for 'Do' by the time HandleOperator dispatches here (#402 round 4). + if (xobjectNameOperand is not PdfName xobjectName) + return; + + if (ctx.Resources is null || !TryGetResource(ctx.Resources, PdfName.XObject, xobjectName, out var entryRaw)) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"'Do' names '/{DiagnosticExcerpt.Quote(xobjectName.Value)}', absent from the applicable " + + "/Resources /XObject dictionary.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + + if (entryRaw is not PdfIndirectReference xobjectRef) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"'Do' names '/{DiagnosticExcerpt.Quote(xobjectName.Value)}', present in the applicable " + + "/Resources /XObject dictionary but not as an indirect reference to a stream.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + + var stream = _reader.ResolveStream(xobjectRef); + if (stream is null) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"'Do' names '/{DiagnosticExcerpt.Quote(xobjectName.Value)}', but object " + + $"{xobjectRef.ObjectNumber} does not resolve to a stream.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return; + } + + if (stream.Dictionary.Get(PdfName.Subtype) is not PdfName subtype) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"'Do' names '/{DiagnosticExcerpt.Quote(xobjectName.Value)}', object {stream.ObjectNumber}, " + + "whose /Subtype is missing or is not a name, so it cannot be used as an XObject.", + stream.ObjectNumber, pageIndex: pageIndex); + return; + } + + if (subtype.Equals(XObjectSubtypeImage)) + return; // An Image XObject: no recursion; the caller already got Do. + + if (!subtype.Equals(XObjectSubtypeForm)) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"'Do' names '/{DiagnosticExcerpt.Quote(xobjectName.Value)}', object {stream.ObjectNumber}, " + + $"whose /Subtype '/{DiagnosticExcerpt.Quote(subtype.Value)}' is neither /Form nor /Image, " + + "so it cannot be used as an XObject.", + stream.ObjectNumber, pageIndex: pageIndex); + return; + } + + var objectNumber = stream.ObjectNumber; + + if (_formInvocations >= MaxFormInvocationsPerPage) + { + diagnostics.ReportRetained( + PdfReaderDiagnosticCode.FormXObjectBudgetExceeded, + $"The page invoked more than {MaxFormInvocationsPerPage} Form XObjects; further " + + "'Do' recursions were skipped for the rest of the page.", + pageIndex: pageIndex); + return; + } + + // The cycle check runs BEFORE the depth cap: with MaxFormXObjectDepth set low (1, say), a + // self-referencing form would otherwise hit the depth cap first and report + // FormXObjectDepthExceeded, which is technically true but strictly less informative than + // FormXObjectCycle, the code that names the actual reason recursion cannot continue + // (#402 round 2). + if (_openForms.Contains(objectNumber)) + { + diagnostics.Report( + PdfReaderDiagnosticCode.FormXObjectCycle, + $"Form XObject {objectNumber} invokes itself, directly or through a chain of nested " + + "'Do' operators; the recursive invocation was skipped.", + objectNumber, pageIndex: pageIndex); + return; + } + + if (_formDepth >= _limits.MaxFormXObjectDepth) + { + diagnostics.Report( + PdfReaderDiagnosticCode.FormXObjectDepthExceeded, + $"Form XObject recursion exceeded {_limits.MaxFormXObjectDepth} levels; this 'Do' " + + "was not followed.", + objectNumber, pageIndex: pageIndex); + return; + } + + _openForms.Add(objectNumber); + // Counts every 'Do' that reaches this point, i.e. every invocation past the recursion + // guards above, not only one whose content goes on to decode successfully (the 305 doc + // says so; #402 round 3 fixed the doc to match, since this counter itself already counted + // this way from the start). + _formInvocations++; + _formDepth++; + try + { + var formDict = stream.Dictionary; + var matrix = ReadFormMatrix(formDict); + var bbox = ReadFormBBox(formDict); + // §8.10.2: a form's /Resources is optional but strongly recommended; when absent, the + // invoking content stream's own resources apply. + var formResources = ResolveDictionaryEntry(formDict, PdfName.Resources) ?? ctx.Resources; + + visitor.OnFormBegin(formDict, matrix, bbox, objectNumber, offset); + try + { + // Checked BEFORE decoding, not after: decoding this invocation's content only to + // discard it once the budget already stood at zero still pays the full decode cost + // (allocation, filter work) for nothing, every single invocation. Against an 8 + // MiB-decoding form drawn 256 times from a 43 KB file, checking after the decode + // measured 6.9 GiB allocated once the budget was already spent on the first few + // invocations; at the 4096-invocation cap with a 512 MiB form, roughly 2 TiB + // (#402 round 2). Every invocation of a form still counts its bytes again against + // this Run's own shared budget when it DOES decode: the cost being bounded is + // interpretation WORK, and a form drawn many times is interpreted that many times, + // not decoded-and-cached once. + byte[]? decoded = null; + if (_contentBytesRemaining > 0) + { + try + { + decoded = _reader.GetDecodedStreamData(stream); + } + catch (InvalidDataException) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + $"Form XObject {objectNumber}'s content stream failed to decode.", + objectNumber, pageIndex: pageIndex); + decoded = null; + } + + if (decoded is not null) + { + ContentStreamsDecoded++; + if (decoded.Length > _contentBytesRemaining) + { + var take = TruncateAtWhitespaceBoundary( + decoded, (int)Math.Min(decoded.Length, _contentBytesRemaining)); + decoded = decoded.AsSpan(0, take).ToArray(); + diagnostics.ReportRetained( + PdfReaderDiagnosticCode.ContentStreamTooLarge, + $"Form XObject {objectNumber}'s content pushed this Run's combined " + + $"page-and-forms budget past {MaxContentBytes / (1024 * 1024)} MiB; " + + "interpretation of it stopped there.", + objectNumber, pageIndex: pageIndex); + // See BuildPageContentBuffer's own remark on why this is forced to + // exactly zero rather than left at whatever the whitespace back-off did + // not use. + _contentBytesRemaining = 0; + } + else + { + _contentBytesRemaining -= decoded.Length; + } + } + } + + if (decoded is not null) + { + var formCtx = new StreamContext(formResources, objectNumber); + + // ISO 32000-2 §8.10.1: Do on a form XObject "a) Saves the current graphics + // state, as if by invoking the q operator" before interpreting the form's own + // content, and "e) Restores [it], as if by invoking the Q operator" once done. + // Implemented with floors rather than an actual _gsStack.Push/Pop so the + // implicit save does not itself consume the invoker's own + // MaxGraphicsStateDepth budget, and the form's own content cannot 'Q' past its + // own entry point back into the invoker's own saves. Marked-content nesting + // (§14.6.1) and BX/EX depth get the same bracketing: nothing a form does to + // either may leak into the invoker once Do returns. The over-cap push credits + // (_ignoredGsPushes/_ignoredMcPushes) are reset to 0 for the form's own scope + // and restored afterward too, so neither side of the boundary can consume a + // credit that belongs to the other's own over-cap pushes (#402 round 2). + // + // §9.4.1's text matrices (_textState) are ALSO saved and restored here, even + // though 'Do' is not itself a text-showing operator and §8.2 Figure 9 admits no + // XObjects-category operator inside a text object at all (see the + // _inTextObject check above): that only says the INVOKING content is wrong + // to call 'Do' from inside a text object, not that the form's OWN content + // cannot open its own, entirely independent text object (a 'BT' resets + // TextMatrix/TextLineMatrix unconditionally, with no check against whatever the + // invoker's own text state happened to be). Nothing else brackets _textState + // off from a form's content the way the floors above do for the graphics and + // marked-content stacks, so without this save/restore a form whose own content + // opens and moves a text object (even one that itself never leaves it open, + // e.g. 'BT 999 888 Td ET') silently overwrote the invoker's own TextMatrix once + // Do returned, with no diagnostic at all (#402 round 2). + var savedGs = _gs; + var savedGsStackCount = _gsStack.Count; + var savedMarkedContentDepth = _markedContentDepth; + var savedBxDepth = _bxDepth; + var savedGsFloor = _gsFloor; + var savedMarkedContentFloor = _markedContentFloor; + var savedBxFloor = _bxFloor; + var savedIgnoredGsPushes = _ignoredGsPushes; + var savedIgnoredMcPushes = _ignoredMcPushes; + var savedTextMatrix = _textState.TextMatrix; + var savedTextLineMatrix = _textState.TextLineMatrix; + var savedInTextObject = _inTextObject; + + _gsFloor = savedGsStackCount; + _markedContentFloor = savedMarkedContentDepth; + _ignoredGsPushes = 0; + _ignoredMcPushes = 0; + // Unlike the graphics-state and marked-content floors just above, BX/EX depth + // does not carry the invoker's own state into the form's content: Table 33 + // scopes one compatibility section to ONE content stream, and a form is its own + // content stream, so it has nothing of the invoker's BX/EX nesting to inherit or + // protect against. Both start at 0 here rather than at the invoker's current + // depth (#402 round 3; without this, a form invoked from inside 'BX ... Do ... + // EX' inherited _bxDepth > 0 from the invoker, which made the form's own unknown + // operators look like they were still inside the INVOKER's compatibility + // section and silently swallowed them, even though the same form invoked as a + // bare 'Do' correctly reported them). + _bxDepth = 0; + _bxFloor = 0; + // The form's own content starts outside any text object whatever the invoker + // was doing: its 'Do' is judged against its own BT/ET, and its ET must not + // close the invoker's text object once Do returns. + _inTextObject = false; + _gs = _gs.Clone(); + + // §8.10.1 b): "Concatenates the matrix from the form dictionary's Matrix entry + // with the current transformation matrix (CTM)". Applied to the CLONE + // above, not the invoker's own _gs, so a visitor reading GraphicsState.Ctm from + // inside the form's own first operator sees the composed matrix while the + // invoker's own CTM, restored in the finally below, is never touched by it + // (#402 round 2: before this, a visitor had no way to recover the composed CTM + // at all, since C0^-1 x M x C0 is undefined for a singular form /Matrix). + _gs.Ctm = matrix.Concat(_gs.Ctm); + + // §7.8.2: operands never carry across an operator, and Do is the operator + // here, so neither an operand left over from before this Do nor one trailing + // the form's own content should reach the operator that follows on either side. + ClearOperands(); + try + { + InterpretStream(decoded, formCtx, visitor, pageIndex, diagnostics); + } + finally + { + ClearOperands(); + while (_gsStack.Count > savedGsStackCount) + _gsStack.Pop(); + _gs = savedGs; + _markedContentDepth = savedMarkedContentDepth; + _bxDepth = savedBxDepth; + _gsFloor = savedGsFloor; + _markedContentFloor = savedMarkedContentFloor; + _bxFloor = savedBxFloor; + _ignoredGsPushes = savedIgnoredGsPushes; + _ignoredMcPushes = savedIgnoredMcPushes; + _textState.TextMatrix = savedTextMatrix; + _textState.TextLineMatrix = savedTextLineMatrix; + _inTextObject = savedInTextObject; + } + } + } + finally + { + visitor.OnFormEnd(objectNumber); + } + } + finally + { + _formDepth--; + _openForms.Remove(objectNumber); + } + } + + private PdfDictionary? ResolveDictionaryEntry(PdfDictionary dict, PdfName key) => + dict.Get(key) is { } raw ? _reader.ResolveValue(raw) as PdfDictionary : null; + + private Matrix ReadFormMatrix(PdfDictionary formDict) + { + // §7.3.10 permits any dictionary entry to be an indirect reference; Table 93 gives /Matrix + // no direct-only restriction, so the entry is resolved before the shape check below. + if (formDict.Get(MatrixKey) is { } raw && _reader.ResolveValue(raw) is PdfArray arr + && arr.Count == 6 && TryReadNumbers(arr, out var v)) + return new Matrix(v[0], v[1], v[2], v[3], v[4], v[5]); + return Matrix.Identity; // §8.10.2 Table 93's own default. + } + + private PdfRectangle? ReadFormBBox(PdfDictionary formDict) + { + // Same reasoning as ReadFormMatrix above: /BBox may be indirect too. + if (formDict.Get(BBoxKey) is { } raw && _reader.ResolveValue(raw) is PdfArray arr + && arr.Count == 4 && TryReadNumbers(arr, out var v)) + { + return new PdfRectangle( + Math.Min(v[0], v[2]), Math.Min(v[1], v[3]), Math.Max(v[0], v[2]), Math.Max(v[1], v[3])); + } + return null; + } + + private bool TryReadNumbers(PdfArray array, out double[] values) + { + values = new double[array.Count]; + for (var i = 0; i < array.Count; i++) + { + var resolved = _reader.ResolveValue(array[i]); + if (resolved is not (PdfInteger or PdfReal)) + return false; + values[i] = ReadNumber(resolved); + } + return true; + } + + // ── Inline images (ISO 32000-2 §8.9.7) ────────────────────────────────────────────────────── + + /// Parses one BIIDEI inline image starting with BI + /// already consumed by the caller. Returns when the image's key/value + /// dictionary or data could not be delimited at all, or when the dictionary hit one of this + /// reader's ceilings ( on a value, + /// on the pair count): the caller stops interpreting + /// this stream either way, since a ceiling drop happens before the image's data has been + /// delimited, leaving nothing past that point to resynchronise on reliably. + private bool HandleInlineImage( + PdfLexer lexer, PdfObjectParser parser, StreamContext ctx, IContentVisitor visitor, + DiagnosticSink diagnostics, int pageIndex, int biOffset) + { + var dict = new PdfDictionary(); + var entryCount = 0; + + while (true) + { + lexer.SkipWhitespaceAndComments(); + if (lexer.AtEnd) + { + ReportInlineImageMalformed( + "the 'ID' operator was never reached before the end of the content stream", ctx, + diagnostics, pageIndex); + return false; + } + + var keyTok = lexer.NextToken(); + if (keyTok.Kind == TokenKind.Keyword && keyTok.Raw.Span.SequenceEqual("ID"u8)) + break; + + if (keyTok.Kind != TokenKind.Name) + { + ReportInlineImageMalformed( + "a key name or 'ID' was expected in the inline image dictionary", ctx, diagnostics, + pageIndex); + return false; + } + + // Checked before this key is even decoded into a PdfName, not after: an over-cap + // dictionary must not keep paying per-pair allocation cost for pairs this reader is + // about to drop the whole image over anyway (#402 round 7; see + // MaxInlineImageDictionaryPairs for the 21-pair bound a Table 91-only dictionary has). + if (entryCount >= MaxInlineImageDictionaryPairs) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ContentLimitExceeded, + $"An inline image dictionary has more than {MaxInlineImageDictionaryPairs} " + + "key-value pairs; the image was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + return false; + } + entryCount++; + + var key = InlineImageAbbreviations.ExpandKey(PdfObjectParser.ParseName(keyTok)); + var isColorSpaceKey = key.Equals(PdfName.ColorSpace); + var isFilterKey = key.Equals(PdfName.Filter); + + lexer.SkipWhitespaceAndComments(); + var valueStart = lexer.Position; + var valueTok = lexer.NextToken(); + + // Pre-scanned with the lexer alone, exactly the way the main operand loop's own + // ArrayBegin/DictBegin case does (see CompositeOperandWithinCap's own remarks), before + // either PdfObjectParser branch below ever materialises the value: an inline image + // dictionary value has no operator-level arity check of its own to fall back on for + // this, so without this pre-scan a value here bypassed MaxCompositeOperandElements + // entirely, even though every other array or dictionary this interpreter parses from + // content is bounded by it (#402 round 7). + if (valueTok.Kind is TokenKind.ArrayBegin or TokenKind.DictBegin) + { + if (!CompositeOperandWithinCap(lexer, out var valueLexerFailed)) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ContentLimitExceeded, + $"An inline image dictionary value exceeds {MaxCompositeOperandElements} " + + "tokens; the image was dropped.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + if (valueLexerFailed) + { + // Same reasoning as the identical branch in the main operand loop's own + // ArrayBegin/DictBegin case above: the count pass itself hit a malformed + // byte before it ever reached the cap comparison this 309 already covers, + // so that failure gets its own 300 too rather than silently ending + // interpretation with nothing to explain why. + diagnostics.Report( + PdfReaderDiagnosticCode.ContentStreamLexError, + "The content stream's syntax could not be interpreted past this " + + "point; interpretation of it stopped here.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + } + return false; + } + } + + PdfObject value; + if (valueTok.Kind == TokenKind.Name && (isColorSpaceKey || isFilterKey)) + { + value = InlineImageAbbreviations.ExpandColorSpaceOrFilterName( + PdfObjectParser.ParseName(valueTok), isColorSpaceKey); + } + else if (valueTok.Kind == TokenKind.ArrayBegin && (isFilterKey || isColorSpaceKey)) + { + lexer.Seek(valueStart); + var arr = (PdfArray)parser.ParseObject(); + var items = new List(arr.Count); + for (var i = 0; i < arr.Count; i++) + { + // §8.9.7 permits exactly one composite inline colour space, "a limited form of + // Indexed colour space" whose base is a device space, written as an array: + // [/I baseSpace hival lookup]. Only elements 0 and 1 are colour-space NAMES + // eligible for Table 92 expansion there (hival is a number, lookup a string or + // stream); a /Filter array has no such shape restriction, so every element of + // one is eligible. + var eligible = isFilterKey || i < 2; + items.Add(arr[i] is PdfName elName && eligible + ? InlineImageAbbreviations.ExpandColorSpaceOrFilterName(elName, isColorSpace: isColorSpaceKey) + : arr[i]); + } + value = new PdfArray(items); + } + else + { + lexer.Seek(valueStart); + value = parser.ParseObject(); + + // §7.8.2: "Indirect objects and object references shall not be permitted at all" + // in a content stream. Table 91's entries each already have a documented default + // or an existing missing-entry diagnostic, so the entry is treated as absent + // rather than dropping the whole image over it: /F 5 0 R falls through to the + // unfiltered-data-length computation from /W /H /BPC /CS, and /W 5 0 R becomes a + // missing /W, which those existing paths already report. + if (value is PdfIndirectReference) + { + diagnostics.Report( + PdfReaderDiagnosticCode.InlineImageMalformed, + "An inline image dictionary value is an indirect reference, which §7.8.2 " + + "does not permit in a content stream; the entry was ignored.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + continue; + } + } + + dict.Set(key, value); + } + + // Computed before the separator-skip logic below (not after, as an earlier version did), + // since the whitespace rule itself depends on which filters are in play (#402 round 2): a + // producer that names ASCIIHexDecode/ASCII85Decode anywhere in a /Filter array gets extra + // whitespace skipped per NOTE 2 below, and deciding that requires already knowing the + // (Table 91/92-expanded) filter names dict.Set left behind while the key/value loop above + // ran. + var filterNames = CollectFilterNames(dict); + var hasDisallowedFilter = filterNames.Any(f => + f.Value is "JBIG2Decode" or "JPXDecode" or "Crypt"); + + // §8.9.7's normative sentence excludes ASCIIHexDecode/ASCII85Decode "as one of its filters" + // from the single-white-space rule; NOTE 2 gives the skip-without-decoding recipe, scoped + // narrower, to "the final or only filter": "if the final or only filter is + // ASCIIHexDecode or ASCII85Decode skip any further white-space [after the first]" before + // counting /L's own bytes. NOTE 2's own skip only ever touches the raw bytes right after + // ID, so its "final" filter can only mean final in ENCODING order: the filter applied LAST + // when the data was written, and therefore the FIRST one a decoder strips. §7.4.1's own + // EXAMPLE 2 fixes what "order" means for a /Filter array: data "encoded using LZW and ASCII + // base-85 encoding (in that order)" (LZW applied first, A85 applied last) decodes through + // "/Filter [/ASCII85Decode /LZWDecode]", A85 named FIRST, because the array is written in + // DECODE order and A85 is what strips the literal bytes first. So NOTE 2's "final" filter is + // always array position 0 (or the sole name), never any later position (#402 round 3: an + // earlier version skipped whenever AHx/A85 appeared ANYWHERE in the array, which corrupts + // data when a different filter is the one reading the raw bytes: '/F [/Fl /A85]' names A85 + // second, so FlateDecode owns the raw bytes, and skipping past the payload's own leading + // byte as though A85 owned that position ate it). hasDisallowedFilter above stays + // position-independent on purpose: the normative sentence's own "as one of its filters" + // is a different question (any of JBIG2Decode/JPXDecode/Crypt at any position is forbidden) + // from which filter reads the raw bytes first. Before the round-2 fix, at most one + // whitespace byte was consumed for every filter shape, so 'BI /F /A85 /L 48 ID <48-byte + // payload> EI' (two spaces after ID) came out 3 bytes short: the fixed-at-one skip left the + // payload's own second byte behind as data, and the rest re-lexed as content instead. + // Decided from the RAW /Filter array's own element 0, not filterNames[0]: CollectFilterNames + // drops a non-name element rather than keeping its place in the array, so '/F [null /A85]' + // would otherwise promote /A85 into filterNames[0] and skip whitespace as though IT were + // position 0, when the array's own position 0 is neither AHx nor A85 at all (#402 round 4). + var skipsExtraWhitespace = FirstFilterIsAsciiHexOrAscii85(dict); + + // §8.9.7: "the ID operator shall be followed by a single white-space character, and the + // next character shall be interpreted as the first byte of image data." Exactly ONE byte is + // consumed as that mandated separator below, even when it is a CR immediately followed by + // an LF, which §7.2.3's own EOL rule folds into one two-byte marker for LINE-ENDING + // purposes only; §8.9.7 does not import that fold, and unlike §7.3.8.1 (which requires "an + // end-of-line marker consisting of either a CARRIAGE RETURN and a LINE FEED or just a LINE + // FEED" outright on the one construct, a stream's own keyword-to-data boundary, where a spec + // author who wanted the fold applied there wrote it in), §8.9.7 says only "a single + // white-space character". isCrLf records whether the mandated byte happened to be a CR + // immediately followed by an LF, so tiers a/b below (each of which has a declared or + // computed length to verify a reading against) can retry the two-byte reading once should + // the one-byte reading fail to land on 'EI' (#402 round 4: reading the pair first fed a + // payload's own leading LF byte to the ID separator instead of to the image data whenever a + // producer wrote a lone CR before a payload that happened to start with LF). + var isCrLf = false; + var oneByteSeparatorPos = lexer.Position; + if (lexer.TryPeek() is var separatorByte && separatorByte >= 0 + && PdfLexer.IsWhitespaceByte((byte)separatorByte)) + { + isCrLf = separatorByte == (byte)'\r' && lexer.Position + 1 < _currentBuffer.Length + && _currentBuffer.Span[lexer.Position + 1] == (byte)'\n'; + lexer.Seek(lexer.Position + 1); + oneByteSeparatorPos = lexer.Position; + + if (skipsExtraWhitespace) + { + while (lexer.TryPeek() is var extraByte && extraByte >= 0 + && PdfLexer.IsWhitespaceByte((byte)extraByte)) + lexer.Seek(lexer.Position + 1); + // NOTE 2's own "skip any further white-space [after the first]" already consumes a + // following LF the same way whether the CR alone or the CR LF pair is read as the + // mandated separator, so the two readings converge on the same position here: there + // is nothing left for tiers a/b's own retry, below, to try a second time. + oneByteSeparatorPos = lexer.Position; + isCrLf = false; + } + } + var foldedSeparatorPos = isCrLf ? oneByteSeparatorPos + 1 : oneByteSeparatorPos; + + var dataStart = oneByteSeparatorPos; + + var length = TryLengthFromDictionary(dict, dataStart, ctx, diagnostics, pageIndex, out var lengthPastEnd); + var usedTierA = length is not null; + if (lengthPastEnd) + { + ReportInlineImageMalformed( + "/L names a length past the end of the content stream", ctx, diagnostics, pageIndex); + } + + if (length is null && filterNames.Count == 0) + length = TryComputeUnfilteredLength(dict, ctx, dataStart, diagnostics, pageIndex); + + var lengthFromScan = false; + if (length is null) + { + // Tier c has no declared or computed length to verify either ID-separator reading + // against, so unlike tiers a/b just above it cannot retry between the two; it keeps + // §7.2.3's own CR-LF fold as its one reading instead (#402 round 4): the fold misjudges + // only a lone-CR separator immediately followed by an LF payload byte, a narrower + // failure mode than the one-byte reading's own would be here, which would prepend a + // spurious LF onto every ordinary CR LF producer's data, the common case, whenever this + // scan has no length to tell the two readings apart with. + dataStart = foldedSeparatorPos; + var scanEnd = ScanForEi(dataStart, ctx.DiagObjectNumber); + if (scanEnd is null) + { + ReportProbeBudgetExhaustedIfNeeded(ctx, diagnostics, pageIndex); + ReportInlineImageMalformed( + "no 'EI' operator delimiting the image data could be found", ctx, diagnostics, + pageIndex); + return false; + } + length = scanEnd.Value - dataStart; + lengthFromScan = true; + } + + // No bounds check needed here: tier a (TryLengthFromDictionary) already rejects a length + // running past the buffer as pastEnd, tier b (TryComputeUnfilteredLength) returns null on + // the same overrun, and tier c (ScanForEi) can only ever return an offset it found by + // scanning forward from dataStart within the buffer. Every path into `length` already + // guarantees dataStart + length.Value falls inside [dataStart, _currentBuffer.Length] + // (#402 round 3: the equivalent check here was dead code no path could reach). + var dataEnd = dataStart + length.Value; + var data = _currentBuffer.Slice(dataStart, length.Value); + var resyncPos = SkipToEi(dataEnd); + + // A tier-a (/L) or tier-b (computed) length that does not land on 'EI' is symmetric with + // the /L-past-the-end case above: both recover through the same EI scan (tier c) rather + // than losing the rest of the content stream outright. Tier c itself is excluded here + // (lengthFromScan) since it already IS that fallback. + if (resyncPos is null && !lengthFromScan) + { + // dataStart is still the one-byte-separator reading here (lengthFromScan is false, so + // tier c's own reassignment above never ran). §8.9.7 gives that one-byte reading no way + // to tell a lone-CR separator immediately followed by an LF payload byte apart from a + // two-byte CR-LF separator, so when it fails to land on 'EI' and the mandated byte was a + // CR immediately followed by an LF, retry once with §7.2.3's own fold: both bytes + // consumed as the separator instead, since a producer that wrote a two-byte CR-LF + // separator is exactly the case the one-byte reading above would otherwise misjudge + // (#402 round 4). This retry only runs when the one-byte reading's own resync above + // already failed to land on 'EI'; it does not cover every CR-LF producer. The CR LF + // pair at the mandated separator is what shifts the one-byte reading off by one in the + // first place (it leaves the LF of the pair at the front of the data instead of + // consuming it as part of the separator); when the payload's own last byte then happens + // to be white space, that shifted reading still lands on 'EI' regardless, because + // SkipToEi skips leading white space before checking for 'EI', so the displaced trailing + // byte gets skipped the same way. The retry never runs in that case, and the visitor + // receives the data shifted one byte, with no diagnostic. That is the reading §8.9.7 + // mandates ("a single white-space character"), not a defect this retry is meant to close. + // + // The malformed report just below is skipped when this retry alone is what recovers the + // image: a conforming file recovered from cleanly must not carry a warning about it + // (#402 round 2). The EI-scan fallback below is a DIFFERENT case: reaching it at all + // means neither reading's declared or computed length landed on 'EI', not merely one of + // the two, so recovering through IT still reports. + var recoveredViaCrRetry = false; + if (isCrLf) + { + var retryStart = foldedSeparatorPos; + var retryEnd = retryStart + length.Value; + if (retryEnd <= _currentBuffer.Length) + { + var retryResync = SkipToEi(retryEnd); + if (retryResync is not null) + { + dataStart = retryStart; + data = _currentBuffer.Slice(dataStart, length.Value); + resyncPos = retryResync; + recoveredViaCrRetry = true; + } + } + } + + if (!recoveredViaCrRetry) + { + var tierName = usedTierA ? "/L" : "the unfiltered image's computed length"; + ReportInlineImageMalformed( + $"the image data length from {tierName} did not land on an 'EI' operator", ctx, + diagnostics, pageIndex); + } + + if (resyncPos is null) + { + // Neither reading's length landed on 'EI', so from here on this is tier c's own + // situation: a scan with no length to verify a reading against. It takes tier c's + // reading too (the CR-LF fold; see the comment on the tier-c branch above) rather + // than keeping the one-byte reading tiers a/b started from, so a CR LF producer + // whose /L is wrong does not get a spurious LF prepended to its recovered data. + dataStart = foldedSeparatorPos; + var scanEnd = ScanForEi(dataStart, ctx.DiagObjectNumber); + if (scanEnd is not null) + { + length = scanEnd.Value - dataStart; + data = _currentBuffer.Slice(dataStart, length.Value); + resyncPos = SkipToEi(dataStart + length.Value); + } + } + } + + if (resyncPos is null) + { + ReportProbeBudgetExhaustedIfNeeded(ctx, diagnostics, pageIndex); + ReportInlineImageMalformed( + "no 'EI' operator was found at the computed end of the image data", ctx, diagnostics, + pageIndex); + return false; + } + + lexer.Seek(resyncPos.Value); + ReportProbeBudgetExhaustedIfNeeded(ctx, diagnostics, pageIndex); + + if (hasDisallowedFilter) + { + ReportInlineImageMalformed( + "the image uses a filter (JBIG2Decode, JPXDecode, or Crypt) never valid on an " + + "inline image (ISO 32000-2 §8.9.7)", ctx, diagnostics, pageIndex); + return true; + } + + visitor.OnInlineImage(dict, data, biOffset); + return true; + } + + // Reports that the resync probe's own MaxProbeBytesPerRun budget ran out before a candidate + // 'EI' could be confirmed, so it (and every later candidate this Run) was accepted unverified + // instead (#402 round 3; see ProbeOnce's Exhausted outcome and ClassifyResyncPoint). Called for + // every inline image this Run still delimits once the budget is spent, not only the one whose + // own scan spent it: the sink's own (code, object, page) dedupe collapses every call against + // the SAME object into one, but a later inline image inside a DIFFERENT content stream (a + // different Form XObject, or the page's own content once a form already spent the budget) is + // not deduped against that first report at all, so this names the offset AND the object number + // (or "the page's own content" when that object number is null) of the FIRST occurrence + // explicitly, rather than pairing the first offset with whatever object happens to be current + // on a later, separately-reported call (#402 round 4). + private void ReportProbeBudgetExhaustedIfNeeded( + StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + if (!_probeBudgetExhausted) + return; + + var firstSpentIn = _probeBudgetExhaustedAtObjectNumber is { } objectNumber + ? $"object {objectNumber}" + : "the page's own content"; + ReportInlineImageMalformed( + $"the resync probe's {MaxProbeBytesPerRun / (1024 * 1024)} MiB per-run byte budget was " + + $"first spent at offset {_probeBudgetExhaustedAtOffset} of {firstSpentIn}, before a " + + "candidate 'EI' there could be confirmed; that candidate, and every later candidate " + + "this run, was accepted without verification", + ctx, diagnostics, pageIndex); + } + + private void ReportInlineImageMalformed( + string reason, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) => + diagnostics.Report( + PdfReaderDiagnosticCode.InlineImageMalformed, $"Inline image malformed: {reason}.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + + private List CollectFilterNames(PdfDictionary dict) + { + var names = new List(); + if (dict.Get(PdfName.Filter) is { } filterVal) + { + switch (filterVal) + { + case PdfName n: names.Add(n); break; + case PdfArray arr: + for (var i = 0; i < arr.Count; i++) + if (arr[i] is PdfName elName) + names.Add(elName); + break; + } + } + return names; + } + + // NOTE 2's "final or only filter" is array position 0 (see the remarks above + // skipsExtraWhitespace's own assignment); this reads that position directly off the RAW + // /Filter value rather than off CollectFilterNames' own result, since a non-name element there + // is dropped rather than kept in place, which would otherwise let a later name slide into + // position 0 (#402 round 4). + private static bool FirstFilterIsAsciiHexOrAscii85(PdfDictionary dict) + { + var first = dict.Get(PdfName.Filter) switch + { + PdfName n => n, + PdfArray { Count: > 0 } arr => arr[0] as PdfName, + _ => null, + }; + return first?.Value is "ASCIIHexDecode" or "ASCII85Decode"; + } + + // Tier (a): /L, PDF 2.0's own Table 91 entry (§8.9.7). See InlineImageAbbreviations' own remarks + // for why this cites Table 91 rather than Table 93, which ISO 32000-2 reserves for a Form + // XObject dictionary's unrelated entries (§8.10.2). + private int? TryLengthFromDictionary( + PdfDictionary dict, int dataStart, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex, + out bool pastEnd) + { + pastEnd = false; + var lengthRaw = dict.Get(PdfName.Length); + if (lengthRaw is null) + // §8.9.7 NOTE 1: Length is new to PDF 2.0, so an older file will not carry the key at + // all; that is tolerated here by falling through to tier b or the EI scan, not treated + // as itself a malformation the way a PRESENT-but-wrong-typed /L is just below. + return null; + + if (lengthRaw is not PdfInteger lengthObj) + { + // Present but the wrong type (a PdfReal, say): reported the same way an invalid /W, + // /H, or /BPC is (#402 round 2), rather than silently falling through to tier b/c as + // if /L had never been written at all. This branch is only reachable once /L IS + // present (the null check above already handled "absent"), so the message names what + // was found wrong with it rather than repeating "missing" (#402 round 3). + ReportInlineImageMalformed( + "'/L' is present but its value is not an integer", ctx, diagnostics, pageIndex); + return null; + } + + if (lengthObj.Value < 0) + { + ReportInlineImageMalformed("'/L' is negative; it was ignored", ctx, diagnostics, pageIndex); + return null; + } + + var length = lengthObj.Value; + if (length > int.MaxValue || dataStart + length > _currentBuffer.Length) + { + pastEnd = true; + return null; + } + + return (int)length; + } + + // Tier (b): unfiltered data, Height x rowBytes, where rowBytes = ceil(Width x BitsPerComponent + // x components / 8) (§8.9.7). + private int? TryComputeUnfilteredLength( + PdfDictionary dict, StreamContext ctx, int dataStart, DiagnosticSink diagnostics, int pageIndex) + { + var isMask = dict.Get(ImageMaskKey) is PdfBoolean { Value: true }; + var width = ReadIntEntry(dict, WidthKey); + var height = ReadIntEntry(dict, HeightKey); + var bpc = isMask ? 1 : ReadIntEntry(dict, BitsPerComponentKey); + + // Table 87 types Width and Height as integer, with no stated sign restriction of its own; + // "positive" is this reader's own requirement (a zero or negative sample count computes + // nothing meaningful), not the table's own wording. BitsPerComponent is different: Table 87 + // restricts its VALUE outright to 1, 2, 4, 8, or (from PDF 1.5) 16, so a value outside that + // set is invalid regardless of sign, not merely non-positive; ImageMask forces bpc to the + // one legal value for a mask (1) above, bypassing this check entirely. ReadIntEntry already + // turns a non-integer or an out-of-int-range /W or /H into "missing" (null). + if (width is null || height is null || bpc is null || width <= 0 || height <= 0 + || (!isMask && bpc is not (1 or 2 or 4 or 8 or 16))) + { + ReportInlineImageMalformed( + "an unfiltered image is missing, or carries an invalid, /W, /H or /BPC needed to " + + "compute its data length", ctx, diagnostics, pageIndex); + return null; + } + + var components = isMask ? 1 : ResolveComponentCount(dict, ctx, diagnostics, pageIndex); + if (components <= 0) + return null; // Unknown colour space: fall back to the EI scan (tier c). + + var rowBytes = ((long)width.Value * bpc.Value * components + 7) / 8; + // rowBytes can reach roughly 2^34 (width up to int.MaxValue, bpc up to 16) and height up to + // int.MaxValue - 1 (~2^31), so this multiply can wrap a signed 64-bit long. The wrap is + // harmless: the total < 0 check below and the bounds check that follows it both still run, + // and a surviving wrapped value that passes both usually takes the did-not-land-on-'EI' + // path instead (a 307 reports first, then the scan, tier c, recovers the image), UNLESS the + // wrapped value happens to land exactly on an 'EI', in which case it is silently accepted + // with no diagnostic at all, the same as any other length that happens to be right. Measured + // with /W 1824726041 /H 1263665316 /BPC 16 /CS /CMYK (rowBytes * height wraps to 32): one + // image delivered, the operators that followed it reached the visitor, and exactly one 307. + // With 32 bytes of image data instead of a mismatched payload, the same wrapped total (32) + // lands squarely on 'EI': one 32-byte image delivered, zero 307s. + var total = rowBytes * height.Value; + if (total < 0 || dataStart + total > _currentBuffer.Length) + return null; // Fall back to the EI scan rather than trusting a runaway computed size. + + return (int)total; + } + + // Table 87 types Width, Height, and BitsPerComponent as integers; a PdfReal there, or an + // integer outside int's range, is invalid rather than merely absent, but both are treated as + // "missing" here so TryComputeUnfilteredLength's own null check catches either uniformly. + private static int? ReadIntEntry(PdfDictionary dict, PdfName key) => dict.Get(key) switch + { + PdfInteger i when i.Value >= int.MinValue && i.Value <= int.MaxValue => (int)i.Value, + _ => null, + }; + + private int ResolveComponentCount( + PdfDictionary dict, StreamContext ctx, DiagnosticSink diagnostics, int pageIndex) + { + var csValue = dict.Get(PdfName.ColorSpace); + + // §8.9.7's one composite inline colour space: [/Indexed base hival lookup]. Indexed + // samples are always single-component index values (§8.6.6.3) regardless of the base + // space's own component count, so this is the one array shape this method needs to + // recognise without resolving the base space at all. + if (csValue is PdfArray csArray && csArray.Count > 0 && csArray[0] is PdfName firstName + && firstName.Value == "Indexed") + return 1; + + if (csValue is not PdfName csName) + return -1; + + if (csName.Equals(PdfName.DeviceRGB)) return 3; + if (csName.Value == "DeviceCMYK") return 4; + if (csName.Value == "DeviceGray") return 1; + + // A bare "/Indexed" name, rather than the array form above, is not itself a legal colour + // space (§8.6.6.3 requires the array shape); it is also not a /Resources /ColorSpace + // entry name a producer would define, so looking it up there and reporting + // ResourceMissing when it is (as expected) absent would be wrong twice over. Falling back + // to the EI scan silently is the simplest correct outcome. + if (csName.Value == "Indexed") + return -1; + + // A named resource colour space (§8.9.7: "the value of the ColorSpace entry may also be the + // name of a colour space in the ColorSpace subdictionary of the current resource + // dictionary"). Resolving its component count in general needs full colour-space semantics + // (ICCBased /N, DeviceN's component array, ...) this interpreter does not implement; + // reporting ResourceMissing when the name is absent, and otherwise falling back to the EI + // scan (tier c) either way, keeps this interpreter's own scope bounded to delimiting the + // image rather than fully understanding its colour space. + if (ctx.Resources is null || !TryGetResource(ctx.Resources, PdfName.ColorSpace, csName, out _)) + { + diagnostics.Report( + PdfReaderDiagnosticCode.ResourceMissing, + $"An inline image's /CS names '/{DiagnosticExcerpt.Quote(csName.Value)}', absent from the " + + "applicable /Resources /ColorSpace dictionary.", + ctx.DiagObjectNumber, pageIndex: pageIndex); + } + return -1; + } + + // Tier (c): scan for whitespace-EI-whitespace/EOF, accepted only when the bounded probe just + // past the candidate (ClassifyResyncPoint) does not reject it outright. A WeakReject verdict + // (see ProbeOutcome's own remarks) is remembered as a fallback rather than rejected on the + // spot: the FIRST such candidate is kept while the scan keeps looking for a stronger one, and + // is returned only once the scan finds no Accept-or-Exhausted candidate at all (#402 round 4). + // Residual gap this scan cannot close on its own (stated here and in the PR body, not fixed: + // closing it needs decoding the image data itself, out of scope for a byte-level scan): any + // whitespace-delimited 'EI' followed by bytes that lex as a Table A.1 operator, as 'BI', or as + // the buffer's own end is indistinguishable, at the byte level, from a resync point the + // terminating 'EI' itself sets, so a false 'EI' followed, after any run of neutral tokens, by + // any of those three shapes is accepted with no diagnostic. A '%' comment is the easiest worked + // example: one running from right after a false 'EI' to the end of its own line can swallow the + // LATER, terminating 'EI' written on that same line, so the operator that follows the comment's + // own line is what the probe sees, and it accepts through the ordinary Table A.1 rule with + // nothing to tell the two apart. The same blind spot applies to a coincidental operator inside + // compressed noise (" EI n ", " EI W ", " EI f " each truncate the image data with no 307 and + // hand the visitor a spurious operator) and to " EI BI " (the BI accept rule below): the bytes + // are well-formed content either way. + private int? ScanForEi(int dataStart, int? diagObjectNumber) + { + var span = _currentBuffer.Span; + int? weakCandidate = null; + for (var i = dataStart; i + 1 < span.Length; i++) + { + if (span[i] != (byte)'E' || span[i + 1] != (byte)'I') + continue; + + var precededByWhitespace = i == dataStart || PdfLexer.IsWhitespaceByte(span[i - 1]); + if (!precededByWhitespace) + continue; + + var after = i + 2; + var followedOk = after >= span.Length + || PdfLexer.IsWhitespaceByte(span[after]) || PdfLexer.IsDelimiterByte(span[after]); + if (!followedOk) + continue; + + var verdict = ClassifyResyncPoint(after, diagObjectNumber); + if (verdict == ResyncVerdict.Reject) + continue; + + var dataEnd = TrimEiDelimiter(span, dataStart, i); + if (verdict == ResyncVerdict.Accept) + return dataEnd; + + // WeakReject: this candidate's own probe ran off the buffer's TRUE end mid-token + // (§8.9.7 gives this scan no way to tell "the file ends with a malformed token" apart + // from "this false 'EI' sits inside image data whose next token happens to run to the + // file's own end without closing"), which is weaker evidence than a malformed byte + // found strictly inside the probe window (still a Reject, above). Only the FIRST one is + // kept: a later, stronger candidate always wins over an earlier weak one. + weakCandidate ??= dataEnd; + } + return weakCandidate; + } + + // Strips the single white-space byte §8.9.7 excludes from the image data (the one delimiting + // 'EI'). §7.2.3 makes a CARRIAGE RETURN immediately followed by a LINE FEED ONE EOL marker, not + // two separate white-space bytes, so when that pair sits right before 'EI' both bytes are the + // delimiter, not just the LF (#402 round 3: stripping only the LF left the CR behind as the + // image's own trailing byte). + private static int TrimEiDelimiter(ReadOnlySpan span, int dataStart, int eiOffset) + { + var dataEnd = eiOffset; + if (eiOffset > dataStart && PdfLexer.IsWhitespaceByte(span[eiOffset - 1])) + { + dataEnd = eiOffset - 1; + if (dataEnd > dataStart && span[dataEnd] == (byte)'\n' && span[dataEnd - 1] == (byte)'\r') + dataEnd--; + } + return dataEnd; + } + + // Confirms an 'EI' candidate at exactly a known offset (used once tier a/b already computed a + // length): skips zero or more §7.2.3 Table 1 white-space bytes, then requires the bytes right + // after to literally spell "EI"; nothing before 'EI' is REQUIRED to be white space, only + // tolerated when present ('/L 4 ID ABCDEIQ ' delimits with no white-space byte immediately + // before 'EI' and no 307). Unlike ScanForEi (tier c), this does not search (it verifies one + // position only) and checks nothing about what follows 'EI': ScanForEi's own followedOk check + // (whitespace or a delimiter right after 'EI') has no counterpart here, so a tier a/b image is + // delivered even when the byte immediately after 'EI' is neither. + private int? SkipToEi(int dataEnd) + { + var pos = dataEnd; + var span = _currentBuffer.Span; + while (pos < span.Length && PdfLexer.IsWhitespaceByte(span[pos])) + pos++; + if (pos + 1 >= span.Length || span[pos] != (byte)'E' || span[pos + 1] != (byte)'I') + return null; + return pos + 2; + } + + // Lexes forward from a candidate resync point until it finds a positive reason to accept or + // reject, or exhausts this Run's own MaxProbeBytesPerRun budget (#402 round 3 redesign; replaces + // the round-2 two-window, token-capped probe, which let every false candidate in a long run of + // them each pay a full window's own lexing cost, driving the " EI (" repeated-candidate shape to + // 16.6 s per decoded MiB, and separately let an unterminated token that merely ran off the + // second, larger window mask the terminating 'EI' whose own legitimate follow-on token + // happened to be longer still). A candidate needs a POSITIVE reason to accept now, not merely + // the absence of a rejecting keyword: a number, name, string, array/dictionary delimiter, + // true/false/null, or an unknown-but-printable keyword is neutral and keeps the probe lexing + // rather than accepting by default, closing the round-2 gap where a straddling but well-formed + // array or dictionary (never itself a keyword) produced the identical wrong "accept" outcome an + // unterminated string used to. + private enum ProbeOutcome + { + /// A Table A.1 operator other than 'EI'/'ID', 'BI', or the buffer's own true end + /// (not merely this probe's budget) was reached. + Accept, + + /// An 'EI' or 'ID' keyword (still inside image data that continues to a LATER + /// 'EI'), a keyword containing a non-printable byte, or a lex failure found strictly inside + /// the window, clipped or not (a malformed byte neither the window's own clip nor the + /// buffer's true end had anything to do with). + Reject, + + /// A lex failure that ran off the buffer's own TRUE end (not this probe's own + /// window clip) trying to close a token: an unterminated literal or hex string with no more + /// buffer left to find its closing delimiter in, say. Weaker evidence than : + /// this scan cannot tell "the file ends mid-token" apart from "this false 'EI' sits + /// inside image data whose next token happens to run to the file's own end without closing" + /// (#402 round 4; see ScanForEi's own remarks on how this outcome is used as a fallback + /// rather than rejected outright). + WeakReject, + + /// The probe's own share of MaxProbeBytesPerRun ran out before it reached an + /// Accept, Reject, or WeakReject outcome. Treated as an accept by ClassifyResyncPoint (see + /// its own remarks), but distinctly, so HandleInlineImage can report that this candidate was + /// accepted unverified rather than confirmed. + Exhausted, + } + + private ProbeOutcome ProbeOnce(int pos) + { + var remaining = _currentBuffer.Length - pos; + var windowLength = (int)Math.Min(remaining, _probeBytesRemaining); + // Whether this window is itself an artificial cap, i.e. more buffer exists beyond it that + // the probe's own remaining budget deliberately does not look at. Only THAT case makes + // reaching the window's own end inconclusive (Exhausted) rather than an outright Accept or + // Reject: a window that reaches the buffer's own true end behaves exactly like the + // unbounded lexer this probe conceptually stands in for (a token that never closes anywhere + // is malformed, full stop; the buffer's own end with nothing pending IS a legitimate resync + // point), so those still resolve outright rather than staying inconclusive. + var windowClipped = windowLength < remaining; + var window = _currentBuffer.Slice(pos, windowLength); + var probe = new PdfLexer(window, contentStreamMode: true); + ProbeOutcome outcome; + + while (true) + { + if (probe.AtEnd) + { + outcome = windowClipped ? ProbeOutcome.Exhausted : ProbeOutcome.Accept; + break; + } + + Token token; + try + { + token = probe.NextToken(); + } + catch (InvalidDataException) + { + // Ran off the end of the window mid-token (an unterminated literal or hex string, + // say). Three cases share this one catch block, distinguished by WHICH end the + // token ran off: the budget's own artificial clip (Exhausted, windowClipped and the + // lexer's own Position landed at or past the window's own length), the buffer's own + // TRUE end with nothing left to close the token (WeakReject, the same Position + // check but an unclipped window: #402 round 4), or a malformed byte found strictly + // inside the window, short of either end, clipped or not (Reject outright). + outcome = (windowClipped, probe.Position >= window.Length) switch + { + (true, true) => ProbeOutcome.Exhausted, + (false, true) => ProbeOutcome.WeakReject, + _ => ProbeOutcome.Reject, + }; + break; + } + + if (token.Kind == TokenKind.EndOfInput) + { + outcome = windowClipped ? ProbeOutcome.Exhausted : ProbeOutcome.Accept; + break; + } + + if (token.Kind != TokenKind.Keyword) + continue; // Neutral: a number, name, string, or array/dictionary delimiter. + + var raw = token.Raw.Span; + if (raw.SequenceEqual("EI"u8) || raw.SequenceEqual("ID"u8)) + { + // Still inside image data that continues to a LATER 'EI': this candidate is a false + // one, not a resync point. + outcome = ProbeOutcome.Reject; + break; + } + if (raw.SequenceEqual("BI"u8)) + { + // The bytes after ITS OWN following 'ID' are raw image data and must not be judged + // as tokens at all, so the probe stops here rather than lexing into them. + outcome = ProbeOutcome.Accept; + break; + } + if (raw.SequenceEqual("true"u8) || raw.SequenceEqual("false"u8) || raw.SequenceEqual("null"u8)) + continue; // Neutral. + if (raw.Length == 1 && raw[0] is (byte)'{' or (byte)'}' or (byte)'>') + continue; // Neutral: this lexer's own one-byte content-mode keywords. + if (ContentOperators.IsKnown(raw)) + { + outcome = ProbeOutcome.Accept; + break; + } + + var hasNonPrintableByte = false; + foreach (var b in raw) + { + if (b is < (byte)'!' or > (byte)'~') + { + hasNonPrintableByte = true; + break; + } + } + if (hasNonPrintableByte) + { + // Binary noise a coincidental "EI" byte pair inside DCT- or JPX-compressed data + // would otherwise be mistaken for legitimate syntax. + outcome = ProbeOutcome.Reject; + break; + } + // Neutral: an unknown-but-printable keyword, the kind of thing §7.8.2 already tolerates + // outside a compatibility section (a future operator this reader does not know yet, a + // stray "R"). + } + + // Charges what this probe spent: the window's own length when it ran out of budget before + // resolving (Exhausted), or however far the lexer got otherwise. A window of length 0 + // (the budget already fully spent when this call started) charges nothing more and + // resolves to Exhausted immediately, which is what makes every candidate after the budget + // runs out cost nothing to probe. + var charged = outcome == ProbeOutcome.Exhausted ? windowLength : Math.Min(probe.Position, windowLength); + _probeBytesRemaining -= charged; + if (_probeBytesRemaining < 0) + _probeBytesRemaining = 0; + ProbeBytesConsumed += charged; + return outcome; + } + + // ScanForEi's own verdict on one candidate: Accept ends the scan immediately, Reject moves on + // to the next candidate with nothing kept, and WeakReject moves on too but leaves the candidate + // behind as a fallback ScanForEi returns if nothing stronger ever turns up (#402 round 4). + private enum ResyncVerdict + { + Accept, + Reject, + WeakReject, + } + + private ResyncVerdict ClassifyResyncPoint(int pos, int? diagObjectNumber) + { + var outcome = ProbeOnce(pos); + if (outcome == ProbeOutcome.Exhausted) + { + // Accepted unverified, once and for the rest of this Run: HandleInlineImage reports + // this the first time it happens, naming the offset AND object number this candidate + // was accepted at without verification (#402 round 4: recording diagObjectNumber here, + // alongside the offset, is what lets a LATER report against a different content + // stream's own object number still name where the budget ran out, rather than + // pairing this offset with whatever object happens to be current when it is reported), + // so a caller can tell "the interpreter confirmed this resync point" apart from "the + // interpreter ran out of budget and took its best guess" (#402 round 3). + if (!_probeBudgetExhausted) + { + _probeBudgetExhausted = true; + _probeBudgetExhaustedAtOffset = pos; + _probeBudgetExhaustedAtObjectNumber = diagObjectNumber; + } + return ResyncVerdict.Accept; + } + return outcome switch + { + ProbeOutcome.Accept => ResyncVerdict.Accept, + ProbeOutcome.WeakReject => ResyncVerdict.WeakReject, + _ => ResyncVerdict.Reject, + }; + } +} diff --git a/src/VellumPdf.Reader/Content/ContentOperators.cs b/src/VellumPdf.Reader/Content/ContentOperators.cs new file mode 100644 index 0000000..868688b --- /dev/null +++ b/src/VellumPdf.Reader/Content/ContentOperators.cs @@ -0,0 +1,172 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +namespace VellumPdf.Reader.Content; + +/// +/// The 73 content-stream operators ISO 32000-2 Annex A Table A.1 lists, alphabetically, with each +/// one's operand count for 's stack-discipline check. A count of +/// marks the four colour operators (SC, sc, SCN, +/// scn): Table 73 (§8.6.8) gives them one numeric operand per colourant of the current +/// colour space, plus, for the N suffix, an optional trailing pattern name, and §8.6.6.5 +/// lets a DeviceN space name "an arbitrary number of colour components", so no single fixed count +/// (nor even a small fixed range) describes them; this interpreter accepts any operand count for them +/// rather than reporting on a +/// legitimately variable call. +/// +internal static class ContentOperators +{ + /// Sentinel operand count for an operator this interpreter does not arity-check. + internal const int Variable = -1; + + private static readonly Dictionary _arity = new(StringComparer.Ordinal) + { + // Table 59: path-painting operators. + ["b"] = 0, + ["B"] = 0, + ["b*"] = 0, + ["B*"] = 0, + ["f"] = 0, + ["F"] = 0, // deprecated alias of f (ISO 32000-2 §8.5.3.1) + ["f*"] = 0, + ["n"] = 0, + ["s"] = 0, + ["S"] = 0, + + // Table 33: compatibility operators. + ["BX"] = 0, + ["EX"] = 0, + + // Table 352: marked-content operators. Annex A Table A.1's own cross-reference for BMC + // names Table 351 ("Entries in a data dictionary") instead, an error in the standard: BDC, + // DP, EMC, and MP all cite Table 352 the way BMC itself should. Table 351 belongs to + // §14.5's page-piece dictionaries (page-piece private data: LastModified, Private) and has + // nothing to do with marked content; a marked-content property list (§14.6.2) is described + // in prose and has no table of its own (#402 round 3). + ["BDC"] = 2, + ["BMC"] = 1, + ["DP"] = 2, + ["EMC"] = 0, + ["MP"] = 1, + + // Table 105/107: text object / text-showing operators. + ["BT"] = 0, + ["ET"] = 0, + ["Tj"] = 1, + ["TJ"] = 1, + ["'"] = 1, + ["\""] = 3, + + // Table 58: path construction operators. + ["c"] = 6, + ["h"] = 0, + ["l"] = 2, + ["m"] = 2, + ["re"] = 4, + ["v"] = 4, + ["y"] = 4, + + // Table 56: graphics state operators. + ["cm"] = 6, + ["d"] = 2, + ["gs"] = 1, + ["i"] = 1, + ["j"] = 1, + ["J"] = 1, + ["M"] = 1, + ["ri"] = 1, + ["w"] = 1, + + // Table 73: colour operators. + ["CS"] = 1, + ["cs"] = 1, + ["G"] = 1, + ["g"] = 1, + ["K"] = 4, + ["k"] = 4, + ["RG"] = 3, + ["rg"] = 3, + ["SC"] = Variable, + ["sc"] = Variable, + ["SCN"] = Variable, + ["scn"] = Variable, + + // Table 60: clipping path operators. + ["W"] = 0, + ["W*"] = 0, + + // Table 76: shading operator. + ["sh"] = 1, + + // Table 86: XObject operator. + ["Do"] = 1, + + // Table 90: inline image operators. Never reach the generic arity check (ContentInterpreter + // intercepts BI before generic operand collection begins), listed here only so the known-set + // membership check (IsKnown) recognises them as legitimate operators. + ["BI"] = 0, + ["ID"] = 0, + ["EI"] = 0, + + // Table 103: text state operators. + ["Tc"] = 1, + ["Tf"] = 2, + ["Tr"] = 1, + ["Ts"] = 1, + ["Tw"] = 1, + ["Tz"] = 1, + ["TL"] = 1, + + // Table 106: text-positioning operators. + ["Td"] = 2, + ["TD"] = 2, + ["Tm"] = 6, + ["T*"] = 0, + + // Table 111: Type 3 font operators. + ["d0"] = 2, + ["d1"] = 6, + + // Table 56: graphics state save/restore. + ["q"] = 0, + ["Q"] = 0, + }; + + /// True when is one of the 73 operators Annex A Table + /// A.1 lists. + internal static bool IsKnown(string operatorName) => _arity.ContainsKey(operatorName); + + // Backs the ReadOnlySpan overload below without re-hashing through a second dictionary: + // StringComparer.Ordinal implements IAlternateEqualityComparer, string>, so + // this alternate lookup shares _arity's own buckets. + private static readonly Dictionary.AlternateLookup> _arityByChars = + _arity.GetAlternateLookup>(); + + /// + /// Span-based overload of for a caller holding a keyword as raw + /// Latin-1 bytes rather than an already-allocated string. The inline-image resync probe + /// (ContentInterpreter.ProbeOnce) checks one candidate keyword per token it lexes, and + /// allocating a string for each one only to discard it after one ContainsKey call was + /// avoidable work on that hot path (#402 round 2). + /// + internal static bool IsKnown(ReadOnlySpan operatorName) + { + // No operator this table lists is longer than a couple of characters; a byte run this much + // longer can never match one, so this bails out before stack-allocating for a length a + // hostile or corrupted stream fully controls. + if (operatorName.Length > 8) + return false; + + Span chars = stackalloc char[operatorName.Length]; + var written = System.Text.Encoding.Latin1.GetChars(operatorName, chars); + return _arityByChars.ContainsKey(chars[..written]); + } + + /// + /// The operand count expects, or for the + /// four colour operators this interpreter does not arity-check. Throws + /// for a name would report + /// false for; every call site checks that first. + /// + internal static int ExpectedOperandCount(string operatorName) => _arity[operatorName]; +} diff --git a/src/VellumPdf.Reader/Content/GraphicsState.cs b/src/VellumPdf.Reader/Content/GraphicsState.cs new file mode 100644 index 0000000..75b019e --- /dev/null +++ b/src/VellumPdf.Reader/Content/GraphicsState.cs @@ -0,0 +1,62 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using VellumPdf.Core; + +namespace VellumPdf.Reader.Content; + +/// +/// The subset of a content stream's graphics state (ISO 32000-2 §8.4) this interpreter tracks: +/// the current transformation matrix, and the text state parameters §9.3.1 makes part of the +/// graphics state (so they save and restore with q/Q along with everything else +/// here). Colour, line width, dash pattern, clipping path, and every other §8.4 parameter are +/// recognised at the operator level (Annex A Table A.1) but not tracked: this interpreter's job is +/// to keep and the text parameters current for a later caller (text extraction, +/// then image extraction, per #98) to read during a visitor callback, not to reproduce a full +/// graphics pipeline. +/// +internal sealed class GraphicsState +{ + /// The current transformation matrix, concatenated by cm (§8.3.4). + internal Matrix Ctm { get; set; } = Matrix.Identity; + + /// Character spacing, Tc (§9.3.2), also settable by "'s own ac + /// operand (Table 107). Added to the horizontal or vertical component of each glyph's + /// displacement, depending on the writing mode. + internal double CharSpacing { get; set; } + + /// Word spacing, Tw (§9.3.3), also settable by "'s own aw operand + /// (Table 107). Added only after a single-byte code 32. + internal double WordSpacing { get; set; } + + /// Horizontal scaling, Tz (§9.3.4), as a percentage; 100 is unscaled. + internal double HorizontalScaling { get; set; } = 100; + + /// Leading, TL (§9.3.5): the line-to-line advance T*, ', and + /// " use, and what TD sets from its own ty operand. + internal double Leading { get; set; } + + /// + /// The font operand from the last Tf or gs-with-/Font (§9.3.1 Table 103, + /// §8.4.5 Table 57): a naming a /Resources /Font entry for + /// Tf, or the an ExtGState's own /Font array names as its + /// font directly (Table 57 requires that to be an indirect reference to a font dictionary) for + /// gs. Not resolved by this interpreter either way: font resolution and glyph + /// positioning are for a later caller to do, not this interpreter's own job. + /// + internal PdfObject? Font { get; set; } + + /// Font size in unscaled text-space units, from the same Tf or gs call + /// that set . + internal double FontSize { get; set; } + + /// Text rendering mode, Tr (§9.3.6): 0–7 per Table 104, not validated here. + internal int RenderMode { get; set; } + + /// Text rise, Ts (§9.3.7): vertical displacement, in unscaled text-space units. + internal double Rise { get; set; } + + /// Deep-copies this state for a q push; Q discards the top and restores + /// the state it copied from. + internal GraphicsState Clone() => (GraphicsState)MemberwiseClone(); +} diff --git a/src/VellumPdf.Reader/Content/IContentVisitor.cs b/src/VellumPdf.Reader/Content/IContentVisitor.cs new file mode 100644 index 0000000..0ec424e --- /dev/null +++ b/src/VellumPdf.Reader/Content/IContentVisitor.cs @@ -0,0 +1,95 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using VellumPdf.Core; +using VellumPdf.Document; + +namespace VellumPdf.Reader.Content; + +/// +/// Receives the events produces while walking a page's content +/// stream (ISO 32000-2 §7.8.2). A caller reads and +/// from inside these callbacks to see the state as of the +/// event; both are mutated in place, so a callback that needs a value after the interpreter has +/// moved on must copy it out rather than hold the reference. Both are also reset to a fresh default +/// the moment returns, so a value read after that point is a +/// fresh default, not the last state Run left behind. +/// +internal interface IContentVisitor +{ + /// + /// Called for every recognised operator (Annex A Table A.1) the interpreter accepts, i.e. after + /// its own operand-count and stack-discipline checks pass. An unrecognised operator (see + /// ) and an operator whose own arity or + /// operand-type check fails (see ) + /// never reach this callback. The one exception is an unbalanced Q or EMC: the + /// interpreter still reaches this callback for it, with no operands, once it has reported the + /// missing q/BMC/BDC to pop, since ignoring the pop is itself the + /// operator's only effect and still needs reporting to a caller tracking operator sequence. + /// + /// The operator keyword, e.g. "Tj" or "re". + /// + /// This operator's operands, in the order they appeared. Owned by the interpreter and reused for + /// the next operator once this call returns, so a callback that needs an operand's value after + /// returning must copy it out, not hold the list. + /// + /// The byte offset, within the buffer currently being interpreted (the + /// page's own concatenated content, or the current Form XObject's own content), of the operator + /// keyword's first byte. + void OnOperator(string operatorName, IReadOnlyList operands, int offset); + + /// + /// Called once an inline image (ISO 32000-2 §8.9.7) has been fully delimited and its dictionary + /// decoded, with Table 91/92 abbreviations already expanded to their full names. Not called at all + /// for an image this interpreter could not delimit or decode + /// (see ). + /// + /// The inline image's key/value pairs, with every Table 91/92 + /// abbreviation already expanded (e.g. /W to /Width, /CS /G to + /// /ColorSpace /DeviceGray). + /// The image's own (still filtered, undecoded) sample data: the bytes between + /// ID and EI, excluding the delimiting white space. A slice over the interpreter's + /// own content buffer (up to the 64 MiB per-run budget), valid only for the duration of this + /// callback, so a callback that needs it after returning must copy it out, not hold the slice: + /// holding it pins the whole buffer alive, not just this image's own share of it. + /// The byte offset of the BI operator that began this image. + void OnInlineImage(PdfDictionary dictionary, ReadOnlyMemory data, int offset); + + /// + /// Called once a Do operator (already reported through ) has + /// been resolved to a /Subtype /Form stream and every recursion guard (depth, cycle, + /// per-page budget) has passed, raised for every Do that reaches that point, including + /// one whose content then fails to decode or is skipped for this run's own content budget: the + /// decode itself, and the budget check ahead of it, both happen AFTER this callback runs, not + /// before it (#402 round 3). Matched by exactly one call once the + /// form's own content finishes interpreting (or is skipped, in the cases above), even if that + /// content raises further nested calls of its own in between. + /// + /// The form XObject's own stream dictionary. + /// The form's /Matrix (ISO 32000-2 §8.10.2 Table 93), or + /// when absent or malformed. The interpreter itself concatenates + /// this into 's own CTM (§8.10.1 b)) before + /// interpreting the form's own content, so a callback reading GraphicsState.Ctm from + /// inside for the form's own first operator already sees the composed + /// value. At the time THIS callback itself runs, GraphicsState.Ctm still holds the + /// invoker's own CTM, since the concatenation happens only after + /// returns. + /// The form's /BBox (Table 93, Required), or + /// when the entry is absent (Table 93 marks it Required, so a + /// here is itself a malformation the visitor may report) or does not + /// resolve to a four-number array. This interpreter raises no new diagnostic of its own for + /// either case. + /// The form stream's own indirect object number. + /// The byte offset of the Do operator that invoked this form, in the + /// buffer the interpreter was walking at the time, i.e. the INVOKING stream's own offset space, + /// not the form's. + void OnFormBegin( + PdfDictionary formDictionary, Matrix formMatrix, PdfRectangle? boundingBox, int objectNumber, + int offset); + + /// Called once a Form XObject's own content has finished interpreting, matching the + /// most recent unmatched call. + /// The same form stream object number + /// reported. + void OnFormEnd(int objectNumber); +} diff --git a/src/VellumPdf.Reader/Content/InlineImageAbbreviations.cs b/src/VellumPdf.Reader/Content/InlineImageAbbreviations.cs new file mode 100644 index 0000000..8efe8d0 --- /dev/null +++ b/src/VellumPdf.Reader/Content/InlineImageAbbreviations.cs @@ -0,0 +1,77 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using VellumPdf.Core; + +namespace VellumPdf.Reader.Content; + +/// +/// The abbreviations ISO 32000-2 §8.9.7 permits inside an inline image's BIID +/// key-value pairs: Table 91's key names, and Table 92's colour-space and filter-name values. +/// +/// +/// /L (the inline-image length entry) belongs to Table 91, alongside every other +/// inline-image key abbreviation; ISO 32000-2 reserves Table 93 for a Form XObject dictionary's own +/// entries (§8.10.2, an unrelated table two clauses later), so this implementation and its +/// citations cite Table 91 for it. +/// +internal static class InlineImageAbbreviations +{ + // Table 91: Entries in an inline image object. + private static readonly Dictionary _keys = new(StringComparer.Ordinal) + { + ["BPC"] = new PdfName("BitsPerComponent"), + ["CS"] = PdfName.ColorSpace, + ["D"] = new PdfName("Decode"), + ["DP"] = new PdfName("DecodeParms"), + ["F"] = PdfName.Filter, + ["H"] = new PdfName("Height"), + ["IM"] = new PdfName("ImageMask"), + ["I"] = new PdfName("Interpolate"), // ambiguous with Table 92's /Indexed "I"; see below + ["L"] = PdfName.Length, + ["W"] = new PdfName("Width"), + }; + + // Table 92: Additional abbreviations in an inline image object (colour spaces and filter names). + // "I" is deliberately excluded from this table: the same one-letter abbreviation "I" stands for + // /Interpolate as a Table 91 KEY abbreviation and for /Indexed as a Table 92 colour-space VALUE + // abbreviation, so which it means depends on where it appears (a dictionary key vs. a /CS + // value), not on the bytes alone. ExpandKey and ExpandColorSpaceOrFilterName each resolve it + // from their own position in the caller's parse, not from this shared table. + private static readonly Dictionary _colorSpacesAndFilters = new(StringComparer.Ordinal) + { + ["G"] = new PdfName("DeviceGray"), + ["RGB"] = PdfName.DeviceRGB, + ["CMYK"] = new PdfName("DeviceCMYK"), + ["AHx"] = new PdfName("ASCIIHexDecode"), + ["A85"] = new PdfName("ASCII85Decode"), + ["LZW"] = new PdfName("LZWDecode"), + ["Fl"] = PdfName.FlateDecode, + ["RL"] = new PdfName("RunLengthDecode"), + ["CCF"] = PdfName.CCITTFaxDecode, + ["DCT"] = PdfName.DCTDecode, + }; + + private static readonly PdfName _indexed = new("Indexed"); + + /// Expands a Table 91 key abbreviation to its full name, or returns + /// unchanged when it is not one of Table 91's abbreviations (including + /// when it is already a full name). + internal static PdfName ExpandKey(PdfName key) => + _keys.TryGetValue(key.Value, out var full) ? full : key; + + /// + /// Expands a Table 92 colour-space-or-filter-name abbreviation. Colour space and filter names + /// share one lookup because Table 92 lists them together and neither can collide with the + /// other's abbreviations (distinct strings), except for the "I" ambiguity documented on + /// : is what tells this + /// method the caller's "I" is a colour-space value (→ /Indexed) rather than the Table 91 key + /// abbreviation (→ /Interpolate) handles instead. + /// + internal static PdfName ExpandColorSpaceOrFilterName(PdfName name, bool isColorSpace) + { + if (isColorSpace && name.Value == "I") // Indexed (Table 92); ExpandKey handles Interpolate + return _indexed; + return _colorSpacesAndFilters.TryGetValue(name.Value, out var full) ? full : name; + } +} diff --git a/src/VellumPdf.Reader/Content/Matrix.cs b/src/VellumPdf.Reader/Content/Matrix.cs new file mode 100644 index 0000000..b38e53a --- /dev/null +++ b/src/VellumPdf.Reader/Content/Matrix.cs @@ -0,0 +1,39 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +namespace VellumPdf.Reader.Content; + +/// +/// A PDF transformation matrix (ISO 32000-2 §8.3.4), written in content streams as six operands +/// a b c d e f representing +/// [ a b 0 ; c d 0 ; e f 1 ]. Applied to a row vector on the left: a point +/// (x, y) maps to (a·x + c·y + e, b·x + d·y + f). +/// +internal readonly record struct Matrix(double A, double B, double C, double D, double E, double F) +{ + /// The identity matrix [1 0 0 1 0 0]: the CTM at the start of every content + /// stream, and Table 93's own default for a form's /Matrix (§8.10.2). + internal static readonly Matrix Identity = new(1, 0, 0, 1, 0, 0); + + /// + /// Composes this matrix with so that applying the result to a point is + /// the same as applying this matrix first, then , i.e. + /// this.Concat(other) == this × other in row-vector convention. This is the operation + /// cm uses to fold its operand matrix into the CTM (§8.3.4: "when a new transformation + /// is concatenated with an existing one, the matrix representing it shall be multiplied + /// before (premultiplied with) the existing transformation matrix", + /// CTM_new = M × CTM_old, i.e. m.Concat(ctm)), and the one Td/TD use + /// to fold a translation into the text line matrix (§9.4.2). + /// + internal Matrix Concat(Matrix other) => new( + A: A * other.A + B * other.C, + B: A * other.B + B * other.D, + C: C * other.A + D * other.C, + D: C * other.B + D * other.D, + E: E * other.A + F * other.C + other.E, + F: E * other.B + F * other.D + other.F); + + /// Builds the translation matrix [1 0 0 1 tx ty] that Td/TD + /// concatenate onto the text line matrix (§9.4.2). + internal static Matrix Translation(double tx, double ty) => new(1, 0, 0, 1, tx, ty); +} diff --git a/src/VellumPdf.Reader/Content/TextState.cs b/src/VellumPdf.Reader/Content/TextState.cs new file mode 100644 index 0000000..1af7731 --- /dev/null +++ b/src/VellumPdf.Reader/Content/TextState.cs @@ -0,0 +1,50 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +namespace VellumPdf.Reader.Content; + +/// +/// The text-positioning matrices ISO 32000-2 §9.4.2 tracks per text object: the text matrix and +/// text line matrix. Deliberately separate from : §9.4.1 says these two +/// matrices (along with the derived text rendering matrix this reader does not track) "may be +/// specified only within a text object and shall not persist from one text object to the +/// next", so they are never saved or restored by q/Q the way §8.4.4's own +/// graphics-state parameters are. Only BT resets them (to identity), and only Td, +/// TD, Tm, T*, and the two Table 107 text-showing operators that also move +/// to the next line, ' and ", update them: ' has the same effect as +/// T* followed by Tj, and " sets Tw and Tc and then behaves as +/// '. Not stacked; the interpreter owns exactly one live instance. +/// +internal sealed class TextState +{ + /// The text matrix, Tm: maps text space to the CTM's user space. + internal Matrix TextMatrix { get; set; } = Matrix.Identity; + + /// The text line matrix: the text matrix at the start of the current line, what + /// Td/TD/T* advance from. + internal Matrix TextLineMatrix { get; set; } = Matrix.Identity; + + /// BT (§9.4.1): resets both matrices to identity at the start of a text object. + internal void BeginText() + { + TextMatrix = Matrix.Identity; + TextLineMatrix = Matrix.Identity; + } + + /// + /// Td/TD (§9.4.2): advances the text line matrix by [1 0 0 1 tx ty] + /// premultiplied against the current text line matrix, then makes the text matrix track it. + /// + internal void MoveTextPosition(double tx, double ty) + { + TextLineMatrix = Matrix.Translation(tx, ty).Concat(TextLineMatrix); + TextMatrix = TextLineMatrix; + } + + /// Tm (§9.4.2): replaces both matrices outright rather than concatenating. + internal void SetTextMatrix(Matrix m) + { + TextMatrix = m; + TextLineMatrix = m; + } +} diff --git a/src/VellumPdf.Reader/DiagnosticExcerpt.cs b/src/VellumPdf.Reader/DiagnosticExcerpt.cs new file mode 100644 index 0000000..7907730 --- /dev/null +++ b/src/VellumPdf.Reader/DiagnosticExcerpt.cs @@ -0,0 +1,42 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using VellumPdf.Core; + +namespace VellumPdf.Reader; + +/// +/// Bounds how much of a producer-controlled name or keyword a retained diagnostic quotes. A +/// diagnostic's job is to identify a malformed token, not to carry the whole thing: neither +/// PdfLexer.ReadKeyword nor bounds a token's own length, and Annex +/// C.1 puts no bound on either ("In general, this PDF standard does not restrict the size or +/// quantity of things described in the PDF file format"; Table C.1's 127-byte name length is only +/// informative). A is retained for the reader's lifetime +/// (), so quoting an oversized token whole would turn one attacker- or +/// corruption-controlled byte run into a comparably sized permanent allocation, once per +/// (code, object, page) the sink's dedupe key admits (#402). +/// +internal static class DiagnosticExcerpt +{ + internal const int MaxChars = 32; + + /// Quotes at most of . + internal static string Quote(string text) => Quote(text, text.Length); + + /// + /// Quotes at most of . + /// is the decoded value's own byte length (Latin1: one char per byte), not necessarily the raw + /// token's: for a it is just text.Length, but a name whose raw + /// token used one or more #xx escapes (§7.3.5) decodes to fewer bytes than it was + /// written in, so the raw token can run longer than reports + /// ('/' + 40 'B' + '#20' x10 is a 71-byte raw token whose decoded Value is 50 bytes, and + /// this reports "(50 bytes)"). The one caller that decodes only far enough to excerpt an + /// oversized keyword (ContentInterpreter.HandleOperator's own dispatch site) passes the + /// raw token's own length separately, since itself is already + /// truncated there. + /// + internal static string Quote(string text, int byteLength) => + byteLength <= MaxChars + ? text + : $"{text[..MaxChars]}... ({byteLength} bytes)"; +} diff --git a/src/VellumPdf.Reader/DiagnosticSink.cs b/src/VellumPdf.Reader/DiagnosticSink.cs index 2300e27..93dfd33 100644 --- a/src/VellumPdf.Reader/DiagnosticSink.cs +++ b/src/VellumPdf.Reader/DiagnosticSink.cs @@ -13,9 +13,13 @@ namespace VellumPdf.Reader; /// exception to that cap: a caller uses it for the rare condition that must stay visible even on a /// document engineered to exhaust the cap on unrelated conditions first, since /// alone offers no way to tell such a condition apart from an ordinary one once the cap is full. -/// is the one caller today, and bounds itself to at most two -/// retained entries per walk, not three, even though three distinct codes each reach -/// (see its own remarks for why). +/// is one caller, and bounds itself to at most two retained entries +/// per walk, not three, even though three distinct codes each reach +/// (see its own remarks for why). ContentInterpreter (#98) is the other: it bounds itself +/// to at most two retained entries per Run the same way, one each for +/// and +/// (see that type's own remarks +/// for why each of those fires at most once). /// /// /// The maximum number of ordinary diagnostics this sink holds before it starts recording @@ -91,10 +95,10 @@ private DiagnosticSink(DiagnosticSink parent) : this(parent._cap) /// it at all ( returns before is ever called). /// /// - /// Unused in this PR (#385 lands only the document-level sink; a per-operation result — the - /// first candidate is #98's text extraction — is what will actually call this). Present now, - /// and exercised directly by DiagnosticSinkTests, so the forwarding contract is pinned - /// before anything depends on it rather than designed against its first real caller. + /// Landed with #385 ahead of any caller, exercised directly by DiagnosticSinkTests so the + /// forwarding contract was pinned before anything depended on it. The first caller is + /// ContentInterpreter.Run (#98), one scope per page interpreted, through + /// . /// /// Each scope holds its own cap-bounded dedupe set (see ), so N /// live scopes retain up to O(N × cap) between them — scopes are meant to be short-lived, diff --git a/src/VellumPdf.Reader/Filters.cs b/src/VellumPdf.Reader/Filters.cs index bf00f48..4e5a8bd 100644 --- a/src/VellumPdf.Reader/Filters.cs +++ b/src/VellumPdf.Reader/Filters.cs @@ -144,9 +144,17 @@ private static byte[] ApplyFilter( // Reported before the throw, not instead of it (#385 routing is observe-only): a caller // that catches the InvalidDataException upstream and keeps the reader alive still sees this // in PdfDocumentReader.Diagnostics, and the reader gave up on the object either way, which - // is what makes this severity Error rather than Warning. + // is what makes this severity Error rather than Warning. The name is excerpted, not + // interpolated whole: a /Filter name has no length bound (Annex C.1), this + // dictionary's filter object is dereferenced once and shared by every stream that + // resolves to it, and a diagnostic is retained for the reader's lifetime, so an + // attacker- or corruption-sized name would become a comparably sized permanent allocation + // once per (code, object, page) the sink's dedupe key admits, per stream (#402 round 8; the + // throw below keeps the whole name, since it is transient and AddElement replaces its + // Message before any caller sees it). diagnostics?.Report( - PdfReaderDiagnosticCode.UnknownFilter, $"Unknown PDF filter: /{filter.Value}.", + PdfReaderDiagnosticCode.UnknownFilter, + $"Unknown PDF filter: /{DiagnosticExcerpt.Quote(filter.Value)}.", objectNumber, generation); throw new InvalidDataException($"Unknown PDF filter: /{filter.Value}"); } diff --git a/src/VellumPdf.Reader/Pages/PageTreeWalker.cs b/src/VellumPdf.Reader/Pages/PageTreeWalker.cs index 25b9cd6..8e3ffcc 100644 --- a/src/VellumPdf.Reader/Pages/PageTreeWalker.cs +++ b/src/VellumPdf.Reader/Pages/PageTreeWalker.cs @@ -93,6 +93,16 @@ internal static class PageTreeWalker // with no usable /Kids of its own: contributes zero children. private static readonly PdfArray EmptyKids = new(); + // A /Type value has no length bound of its own (Annex C.1: "In general, this PDF standard does + // not restrict the size or quantity of things described in the PDF file format"), and both + // diagnostics that quote it (PageTreeMissing on the tree's own root, PageTreeNodeMalformed on + // any other node) are retained for the reader's own lifetime (DiagnosticSink), so interpolating + // an oversized /Type name whole would turn one attacker- or corruption-controlled byte run into + // a comparably sized permanent allocation, once per (code, object) the sink's own dedupe key + // admits: the same class of defect DiagnosticExcerpt exists to bound (#402). + private static string QuoteType(PdfName? type) => + type is null ? "no /Type" : "/" + DiagnosticExcerpt.Quote(type.Value); + /// Walks 's page tree, reporting shape problems to /// along the way, and returns the pages found in tree order. internal static List Walk(PdfDocumentReader reader, DiagnosticSink diagnostics) @@ -158,7 +168,9 @@ internal static List Walk(PdfDocumentReader reader, DiagnosticSink var rootKind = ClassifyByType(reader, cache, rootDict, rootKidsArray, out var rootType); if (rootKind != NodeKind.Node) { - var found = rootType is not null ? $"/Type {rootType}" : "no /Type and no /Kids array"; + var found = rootType is not null + ? $"/Type {QuoteType(rootType)}" + : "no /Type and no /Kids array"; diagnostics.Report( PdfReaderDiagnosticCode.PageTreeMissing, $"The page tree root is not a page-tree node ({found}); ISO 32000-2 §7.7.2 Table " @@ -480,7 +492,7 @@ private static NodeKind ClassifyNode( case NodeKind.Skip: diagnostics.Report( PdfReaderDiagnosticCode.PageTreeNodeMalformed, - $"Object declares {type} where a page-tree node or page object was expected " + $"Object declares {QuoteType(type)} where a page-tree node or page object was expected " + "(ISO 32000-2 §7.7.3.2 Table 30, §7.7.3.3 Table 31); it was skipped.", NullIfZero(objectNumber)); kids = EmptyKids; diff --git a/src/VellumPdf.Reader/PdfDocumentReader.Content.cs b/src/VellumPdf.Reader/PdfDocumentReader.Content.cs new file mode 100644 index 0000000..7408edb --- /dev/null +++ b/src/VellumPdf.Reader/PdfDocumentReader.Content.cs @@ -0,0 +1,19 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +namespace VellumPdf.Reader; + +public sealed partial class PdfDocumentReader +{ + /// + /// Creates a fresh scope (see ) + /// forwarding into this reader's own diagnostics. ContentInterpreter.Run is the first + /// caller of (see that method's own remarks), creating + /// one scope per page interpreted so a caller who runs the interpreter over the same page more + /// than once (text extraction, then image extraction, per #98) gets a fresh, page-scoped + /// dedupe set each time rather than sharing one that silently goes quiet on a second pass's own + /// first occurrence of a condition the first pass already reported into it. Reports made through + /// the returned scope still land in , under this reader's own cap. + /// + internal DiagnosticSink CreateContentDiagnosticScope() => _diagnostics.CreateScope(); +} diff --git a/src/VellumPdf.Reader/PdfLexer.cs b/src/VellumPdf.Reader/PdfLexer.cs index 8be3a03..54be981 100644 --- a/src/VellumPdf.Reader/PdfLexer.cs +++ b/src/VellumPdf.Reader/PdfLexer.cs @@ -77,6 +77,21 @@ internal sealed class PdfLexer { private readonly ReadOnlyMemory _data; + // Off by default: every existing consumer (the object parser, the 13 Conformance types (11 + // rules and two helpers) that build their own PdfLexer, and every non-content test) relies on + // the file-structure lexer's stricter rule that a lone '{', '}', or unmatched '>' is malformed + // PDF. §7.2.3 Table 2 lists '{' and '}' as delimiters with no syntax of their own in the + // file-structure grammar; the one place they mean anything is inside a Type 4 PostScript + // calculator function body (§7.10.5), which this lexer is never pointed at, so the + // file-structure grammar itself has no production for a lone one and it stays malformed in + // default mode. A content stream is a different grammar: those same bytes are junk with no + // meaning of their own there either, but §7.8.2's BX/EX rule is the reason to tolerate junk + // inside a compatibility section rather than abort the whole page over it, so ContentInterpreter + // needs to lex them as harmless one-byte unknown-operator keywords instead. Opt-in, not a + // runtime detection of "is this a content stream", keeps that decision with the caller that + // already knows which grammar applies instead of guessing from the bytes. + private readonly bool _contentStreamMode; + /// Current byte offset within . public int Position { get; private set; } @@ -93,11 +108,24 @@ public PdfLexer(ReadOnlyMemory data, int offset = 0) Position = offset; } - // ── ISO 32000-2 §7.2.2 — whitespace bytes ───────────────────────────── + /// + /// Creates a lexer over in content-stream mode (ISO 32000-2 §7.8.2): a + /// lone {, }, or unmatched > lexes as a one-byte + /// token instead of throwing . Every other token kind, and every + /// other lexer behaviour, is byte-identical to the default constructor's. This is strictly an + /// opt-in relaxation, never a stricter mode. + /// + internal PdfLexer(ReadOnlyMemory data, bool contentStreamMode) + { + _data = data; + _contentStreamMode = contentStreamMode; + } + + // ── ISO 32000-2 §7.2.3 Table 1: whitespace bytes ────────────────────── private static bool IsWhitespace(byte b) => b is 0 or 9 or 10 or 12 or 13 or 32; - // ── ISO 32000-2 §7.2.2 — delimiter bytes ────────────────────────────── + // ── ISO 32000-2 §7.2.3 Table 2: delimiter bytes ─────────────────────── private static bool IsDelimiter(byte b) => b is (byte)'(' or (byte)')' or (byte)'<' or (byte)'>' or (byte)'[' or (byte)']' or (byte)'{' or (byte)'}' @@ -175,8 +203,10 @@ public ReadOnlyMemory Slice(int offset, int length) } /// - /// Advances the cursor to . - /// Must be >= current and <= . + /// Moves the cursor to , forward or backward. Only + /// 0 <= position <= is enforced; several callers deliberately + /// rewind past a position already read (a lookahead scan that backs off after deciding not to + /// consume it, say) and re-lex from there. /// public void Seek(int position) { @@ -224,6 +254,14 @@ public Token NextToken() Position += 2; return new Token(TokenKind.DictEnd, _data.Slice(start, 2)); } + if (_contentStreamMode) + { + // §7.8.2's compatibility section (BX/EX) tolerates PostScript-heritage tokens this + // grammar has no other use for; ContentInterpreter treats the resulting one-byte + // Keyword as just another unrecognised operator, same as '{' and '}' below. + Position++; + return new Token(TokenKind.Keyword, _data.Slice(start, 1)); + } throw new InvalidDataException( $"Unexpected '>' at offset {Position} (not part of '>>'); malformed PDF."); } @@ -244,6 +282,13 @@ public Token NextToken() if (IsRegular(b)) return ReadKeyword(start); + if (_contentStreamMode && (b == (byte)'{' || b == (byte)'}')) + { + // See the '>' branch above and this lexer's content-stream-mode constructor doc. + Position++; + return new Token(TokenKind.Keyword, _data.Slice(start, 1)); + } + throw new InvalidDataException( $"Unexpected byte 0x{b:X2} at offset {Position}."); } diff --git a/src/VellumPdf.Reader/PdfObjectParser.cs b/src/VellumPdf.Reader/PdfObjectParser.cs index 5962e40..1117f8a 100644 --- a/src/VellumPdf.Reader/PdfObjectParser.cs +++ b/src/VellumPdf.Reader/PdfObjectParser.cs @@ -292,7 +292,13 @@ private static PdfReal ParseReal(Token token) return new PdfReal(d); } - private static PdfName ParseName(Token token) + /// + /// Decodes a token's raw bytes (including the leading /) into + /// a , applying #XX escapes (ISO 32000-2 §7.3.5). Internal, not + /// private, so ContentInterpreter's own name-operand decoding shares this rather than + /// duplicating it. + /// + internal static PdfName ParseName(Token token) { // token.Raw includes the leading '/' var raw = token.Raw.Span[1..]; // skip '/' diff --git a/src/VellumPdf.Reader/PdfReaderDiagnostic.cs b/src/VellumPdf.Reader/PdfReaderDiagnostic.cs index c638885..b229a67 100644 --- a/src/VellumPdf.Reader/PdfReaderDiagnostic.cs +++ b/src/VellumPdf.Reader/PdfReaderDiagnostic.cs @@ -349,6 +349,219 @@ public enum PdfReaderDiagnosticCode /// PageTreeNodeLimitExceeded = 207, + // ── 3xx: content streams ──────────────────────────────────────────────────────────────────── + + /// + /// The content-stream interpreter (ISO 32000-2 §7.8.2) hit an + /// from the lexer or object parser partway through a page's content, or a member of the page's + /// /Contents array (§7.7.3.3 Table 31) did not resolve to a usable stream at all: a + /// non-stream element, a reference that fails to resolve, or a stream whose filter chain could + /// not be decoded (including one carrying an image filter, which this interpreter never + /// attempts to decode as content). Operators already reported to the caller's visitor before + /// the failure are kept either way, but the two cases end a different amount of the page's + /// content (#402 round 3): an element that fails to resolve or decode is skipped, and + /// interpretation resumes with the NEXT stream in a multi-stream /Contents array, since + /// every element still contributes its own decoded bytes to one buffer built from the whole + /// array before interpretation of any of it begins. A lexer or parser failure happens INSIDE + /// that already-concatenated buffer, so it ends interpretation for the rest of the page's + /// content outright, later /Contents array elements included, not merely the one + /// stream the failure happened to fall in. + /// + /// Two further cases report this same code but concern neither a /Contents element nor + /// the page's own concatenated buffer (#402 round 4): a Form XObject's own content stream + /// (§8.10) failing to decode, reported against that form's own object number and skipping only + /// that one Do invocation's descent, the rest of the page's content (and any sibling + /// invocation of the same form) unaffected; and an reaching + /// ContentInterpreter.Run's own outer catch from a malformed indirect-reference chain met + /// while resolving a resource, XObject, or nested Form XObject, which ends interpretation of the + /// whole page (nothing narrower than the page itself is available to resume from at that point). + /// + /// + ContentStreamLexError = 300, + + /// + /// A content-stream keyword token is not one of the 73 operators ISO 32000-2 Annex A Table A.1 + /// lists, and it did not appear inside a BX/EX compatibility section (§7.8.2). ISO + /// 32000-2 says "an error shall occur" for this case outside such a section; this reader instead + /// reports it and continues, the same notify-and-continue choice every other diagnostic in this + /// channel makes. Reported at most once per page, because the sink's dedupe key is + /// (code, object, page) and this report carries no object number: a second distinct + /// unrecognised name on the same page is deduped away rather than reported. Silent inside a + /// compatibility section, per Table 33's own text: "Unrecognised operators ... shall be ignored + /// without error." + /// + UnknownOperator = 301, + + /// + /// A content stream's operand-stack discipline broke down in one of several ways this interpreter + /// groups under one code rather than one each, since every case has the same remedy: drop the + /// offending operator (or, for an unbalanced Q/EMC, drop the pop) and keep + /// interpreting. Every case here is producer-side except a numeric literal whose value exceeds + /// this reader's own or range, which is reported here, + /// rather than as , because what gets dropped is the operand + /// itself, not an operator or a push; see for the cases that + /// are this reader's own processing ceiling instead. Covers: a number token that does not parse, + /// is not finite, or carries a second sign character (--5, -+5: §7.3.3 allows only + /// "an optional sign"), a dictionary operand on an operator other than BDC/DP + /// (§7.8.2: "Dictionaries shall be permitted as operands only by certain specific operators"), a + /// known operator invoked with the wrong operand count for its own arity (Annex A Table A.1), an + /// operand of the wrong type where the arity is otherwise right: a TJ whose single operand + /// is not an array (§9.4.3); a non-numeric operand to cm, Tc, Tw, Tz, + /// TL, Tf's second, Tr, Ts, Td, TD, or Tm; a + /// non-name operand to Tf's first, to Do, or to + /// gs/cs/CS/sh (this reader reads that operand for its own resource + /// lookup, the same reason Do's own name operand is checked); a non-string operand to + /// ', or a non-numeric first or second or non-string third operand to " (Table 107) + /// (#402 rounds 3 and 4; every operator this interpreter recognises but does not name above only + /// forwards its own operands to the visitor untouched, so their operand types are the visitor's to + /// type-check, not this interpreter's); a Do that occurred inside a text object (§8.2 + /// Figure 9 admits no operator of Table 50's XObjects category there), an unbalanced Q with + /// no matching q on the graphics-state stack, or an unbalanced EMC with no matching + /// BMC/BDC (§14.6.1). An unbalanced q still open at the end of a content + /// stream is not reported: nothing downstream of this interpreter needs the graphics state + /// restored past the last operator it saw. + /// + OperandStackMalformed = 302, + + /// + /// A Form XObject Do (ISO 32000-2 §8.10) recursed past + /// levels deep. Descent into that subtree + /// stops; the Do operator itself is still reported to the caller's visitor, only the + /// recursive walk into the form's own content is skipped. + /// + FormXObjectDepthExceeded = 303, + + /// + /// A Form XObject's own content, directly or through a chain of nested Do invocations, + /// draws itself again: the same indirect object number already open on the interpreter's own + /// recursion stack (ISO 32000-2 §8.10 describes no cycle of this kind as legal; a form's content + /// is ordinary content that may invoke any XObject, so nothing in the format itself prevents a + /// producer from writing one). Reported once per cycle found; the recursive invocation that + /// would close the cycle is skipped rather than recursing forever. + /// + FormXObjectCycle = 304, + + /// + /// A single page invoked Form XObjects (Do invocations that reached the form, counted + /// across the whole page, not per subtree, and including one whose content then failed to + /// decode or was skipped for the run's own content budget (#402 round 3)) more than 4096 + /// times. ISO 32000-2 places no limit of its own on how deeply or how often a page may invoke + /// Do (§8.10); this cap is this reader's own bound against a wide, shallow invocation + /// graph a depth cap alone would not catch. Descent into any further form stops for the rest of + /// the page; operators already reported before the budget was reached are kept, and + /// interpretation of the page's own (non-form) content continues past the point where the + /// budget was hit. Reported through DiagnosticSink.ReportRetained: a condition that ends + /// the page's own form recursion for good is worth surfacing even once + /// is spent on earlier, unrelated conditions. + /// + FormXObjectBudgetExceeded = 305, + + /// + /// A Do, gs, Tf, cs/CS, or sh operator, or an inline + /// image's /CS (/ColorSpace) entry, named a resource absent from the applicable + /// /Resources subdictionary (ISO 32000-2 §7.8.3): the page's own, or, inside a Form + /// XObject, that form's own /Resources falling back to its parent's when absent + /// (§8.10.2). Also covers Do naming an /XObject entry that IS present but is not + /// a usable XObject stream (#402 round 3): not an indirect reference, does not resolve to a + /// stream, or resolves to one whose /Subtype is missing, is not a name, or names neither + /// /Form nor /Image (Table 86). The operator is still reported to the caller's + /// visitor either way; only the interpreter's own attempt to resolve or use the name failed. + /// + ResourceMissing = 306, + + /// + /// An inline image (ISO 32000-2 §8.9.7) could not be delimited, one of its dictionary entries was + /// itself invalid, or this run's own resync-probe budget ran out before a candidate EI + /// could be confirmed (#402 round 3; see ContentInterpreter.ProbeOnce): a filter this + /// interpreter never applies to inline image data (JBIG2Decode, JPXDecode, or + /// Crypt: §8.9.7 itself excludes all three from inline-image use), a missing ID or + /// EI operator, a missing, non-integer, or non-positive /W or /H, a missing + /// /BPC or one whose value is not 1, 2, 4, or 8 (or, from PDF 1.5, 16) where the image's + /// shape requires one to compute the data length (Table 87 types /W and /H as + /// integer and restricts /BPC's own value to that fixed set; "positive" for + /// /W//H is this reader's own requirement, not the table's own wording), a + /// dictionary value that is an indirect reference, which §7.8.2 does not permit in a content + /// stream, in which case that entry is ignored, an /L present but not an integer, or + /// negative (§8.9.7, Table 91; PDF 2.0), an /L + /// naming a length past the end of the stream (a computed length that overruns the stream instead + /// falls back to the EI scan below with no report of its own), a computed length (from + /// /L or from the image's own shape) that does not land on the following EI + /// operator, in which case this reader retries the EI scan before giving up, or the resync + /// probe spending its whole per-run byte budget before it could confirm a candidate EI, in + /// which case that candidate is accepted unverified rather than left unresolved. The inline-image + /// callback is raised whenever the image was delimited AND its filter chain is one this reader + /// accepts on an inline image, including after an /L-past-the-end or + /// did-not-land-on-EI recovery, or a probe-budget-exhausted acceptance (#402 round 4: a + /// disallowed filter delimits the image just as successfully as an accepted one, so "was + /// delimited" alone is not what decides whether the callback fires). A disallowed filter + /// (JBIG2Decode, JPXDecode, or Crypt) still delimits the image, so + /// interpretation of the rest of the content stream continues past its EI, but skips the + /// callback for that image; when the image could not be delimited at all (no ID, no + /// EI) interpretation of that stream stops there instead, since nothing past that point can + /// be resynchronised reliably. + /// Reported at most once per content stream (the page's own content, or one Form XObject) per + /// page: the sink's dedupe key is (code, object, page), and every image on one content stream + /// reports against that stream's own object number (or, for the page's own top-level content + /// specifically, ), so a second report of this code against the same + /// content stream, whether from another image or from a second malformation of the same one, + /// is not listed separately, but the same page's own content and each Form XObject it draws + /// dedupe independently, since each carries a distinct object number here: a page invoking two + /// forms that each carry their own malformed inline image lists two reports (#402 round 4). + /// + InlineImageMalformed = 307, + + /// + /// This run's combined decoded-content budget, 64 MiB shared across the page's own + /// /Contents (ISO 32000-2 §7.7.3.3 Table 31) and every Form XObject it draws (§8.10), was + /// exceeded. /Contents is concatenated across every stream in its array with a newline + /// inserted between streams so a token is never glued across a stream boundary; a Form XObject + /// is counted again on every invocation, not once per distinct form object, since the + /// interpretation work a repeatedly-drawn form costs scales with invocations, not with how many + /// distinct form objects a page names. Interpretation proceeds up to the point the budget ran + /// out and stops there; operators reported before that point are kept. Reported through + /// DiagnosticSink.ReportRetained, and at most once per run: the truncation this reports + /// also drives the run's own remaining budget to exactly zero, so no later stream in the same + /// run can trigger a second report. + /// + ContentStreamTooLarge = 308, + + /// + /// A content stream hit one of this reader's own processing ceilings rather than being + /// malformed by its producer: more than 64 operands accumulated before an operator (§7.8.2 + /// gives an operator's own operand count no declared bound of its own), an array or dictionary + /// operand (a TJ array, §9.4.3, being the usual one) carrying more than 8192 tokens + /// counted at every nesting depth, more than 64 nested q saves, marked-content nesting + /// (§14.6.1) past the same 64-deep cap, an inline image dictionary (§8.9.7) value that is an + /// array or dictionary carrying more than 8192 tokens by that same count, or an inline image + /// dictionary carrying more than 64 key-value pairs. Split out from + /// (#402) so a caller can tell "this file hit a limit of + /// this reader" apart from "this file is malformed", a distinction the two codes sharing one + /// value made impossible to draw. + /// + /// Recovery differs by which ceiling fired. For the operand-count, composite-token, and depth + /// ceilings, the offending operator, or push, is dropped and interpretation continues, the + /// same recovery uses. For the two inline-image ceilings, + /// the image itself is dropped and interpretation of that content stream (the page's content, + /// or the one Form XObject being drawn) ends there instead, the way + /// ends it when an image cannot be delimited at all: the + /// drop happens before the image's data has been delimited, so nothing past that point can be + /// resynchronised reliably either. A q, BMC, or BDC dropped for any + /// ceiling other than the two inline-image ones still consumes its matching Q or + /// EMC silently, so a conformant file is not also charged an + /// for this reader's own ceiling. + /// + /// + /// The inline image dictionary's 64-pair cap rejects nothing §8.9.7 itself requires: §8.9.7 + /// says entries other than the ones Table 91 lists "shall be ignored" and sets no count, so a + /// dictionary of 65 ignorable entries is not, by that clause, non-conformant. A dictionary + /// that uses only the keys Table 91 defines, under their full names or the abbreviations the + /// same table gives them, has at most 21 pairs, and Table 92 adds abbreviations for values + /// (colour spaces and filters), not further keys. The cap exists to bound how much work a + /// hostile dictionary can make this reader do per pair, not to reject a legal one. + /// + /// + ContentLimitExceeded = 309, + // ── 9xx: reserved ─────────────────────────────────────────────────────────────────────────── /// @@ -408,6 +621,16 @@ internal static class PdfReaderDiagnosticSeverities PdfReaderDiagnosticCode.PageAttributeInvalid => PdfReaderDiagnosticSeverity.Warning, PdfReaderDiagnosticCode.PageTreeNodeMalformed => PdfReaderDiagnosticSeverity.Warning, PdfReaderDiagnosticCode.PageTreeNodeLimitExceeded => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.ContentStreamLexError => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.UnknownOperator => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.OperandStackMalformed => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.FormXObjectDepthExceeded => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.FormXObjectCycle => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.FormXObjectBudgetExceeded => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.ResourceMissing => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.InlineImageMalformed => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.ContentStreamTooLarge => PdfReaderDiagnosticSeverity.Warning, + PdfReaderDiagnosticCode.ContentLimitExceeded => PdfReaderDiagnosticSeverity.Warning, PdfReaderDiagnosticCode.DiagnosticsSuppressed => PdfReaderDiagnosticSeverity.Warning, _ => throw new UnreachableException($"No severity is mapped for {code}."), }; diff --git a/src/VellumPdf.Reader/PdfReaderOptions.cs b/src/VellumPdf.Reader/PdfReaderOptions.cs index dcf7f0b..61ff36b 100644 --- a/src/VellumPdf.Reader/PdfReaderOptions.cs +++ b/src/VellumPdf.Reader/PdfReaderOptions.cs @@ -124,4 +124,26 @@ public sealed class PdfReaderOptions /// of 1000 or below the floor of 1. /// public int MaxDiagnostics { get; init; } = ReaderLimits.DefaultMaxDiagnostics; + + /// + /// The nesting-depth ceiling the internal content-stream interpreter enforces on a recursive + /// Form XObject Do invocation (ISO 32000-2 §8.10): a form that itself draws a form, that + /// draws a form, and so on. Defaults to 32. ISO 32000-2 places no limit on this: a form's own + /// content stream is ordinary PDF syntax that may invoke any XObject, including another form, + /// so the ceiling is this processor's own choice against adversarial input (Annex C.1, + /// informative, on practical processing limits), the same rationale as + /// above. Exceeding it reports + /// PdfReaderDiagnosticCode.FormXObjectDepthExceeded and stops descending into that + /// subtree rather than recursing further (an uncatchable + /// is the alternative a hostile self-referential or deeply-chained form graph would otherwise + /// risk). Tighten-only, matching , + /// , and above: nothing + /// about this cap is a spec requirement, so a caller may lower it but not raise it past the + /// shipped default. + /// + /// + /// Thrown by when set above the default + /// of 32 or below the floor of 1. + /// + public int MaxFormXObjectDepth { get; init; } = ReaderLimits.DefaultMaxFormXObjectDepth; } diff --git a/src/VellumPdf.Reader/PublicAPI.Unshipped.txt b/src/VellumPdf.Reader/PublicAPI.Unshipped.txt index 808eddd..f54877a 100644 --- a/src/VellumPdf.Reader/PublicAPI.Unshipped.txt +++ b/src/VellumPdf.Reader/PublicAPI.Unshipped.txt @@ -37,15 +37,23 @@ VellumPdf.Reader.PdfReaderDiagnostic.PageIndex.get -> int? VellumPdf.Reader.PdfReaderDiagnostic.Severity.get -> VellumPdf.Reader.PdfReaderDiagnosticSeverity override VellumPdf.Reader.PdfReaderDiagnostic.ToString() -> string! VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.ContentLimitExceeded = 309 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.ContentStreamLexError = 300 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.ContentStreamTooLarge = 308 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.DecodeParmsMalformed = 108 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.DecodedStreamLimitExceeded = 111 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.DiagnosticsSuppressed = 900 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.FilterArrayElementNotName = 106 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.FilterNull = 105 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.FilterValueMalformed = 107 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.FormXObjectBudgetExceeded = 305 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.FormXObjectCycle = 304 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.FormXObjectDepthExceeded = 303 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.InlineImageMalformed = 307 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.ObjectGenerationMismatch = 104 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.ObjectHeaderMismatch = 103 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.ObjectStreamContainerUnreadable = 102 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.OperandStackMalformed = 302 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.OrphanedObjectStreamMembersDropped = 101 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.PageAttributeInvalid = 205 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.PageTreeCycle = 201 -> VellumPdf.Reader.PdfReaderDiagnosticCode @@ -55,7 +63,9 @@ VellumPdf.Reader.PdfReaderDiagnosticCode.PageTreeLeafLimitExceeded = 203 -> Vell VellumPdf.Reader.PdfReaderDiagnosticCode.PageTreeMissing = 200 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.PageTreeNodeLimitExceeded = 207 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.PageTreeNodeMalformed = 206 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.ResourceMissing = 306 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.UnknownFilter = 110 -> VellumPdf.Reader.PdfReaderDiagnosticCode +VellumPdf.Reader.PdfReaderDiagnosticCode.UnknownOperator = 301 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.UnsupportedPredictor = 109 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticCode.XrefReconstructed = 100 -> VellumPdf.Reader.PdfReaderDiagnosticCode VellumPdf.Reader.PdfReaderDiagnosticSeverity @@ -69,6 +79,8 @@ VellumPdf.Reader.PdfReaderOptions.MaxDecodedStreamBytes.get -> long VellumPdf.Reader.PdfReaderOptions.MaxDecodedStreamBytes.init -> void VellumPdf.Reader.PdfReaderOptions.MaxDiagnostics.get -> int VellumPdf.Reader.PdfReaderOptions.MaxDiagnostics.init -> void +VellumPdf.Reader.PdfReaderOptions.MaxFormXObjectDepth.get -> int +VellumPdf.Reader.PdfReaderOptions.MaxFormXObjectDepth.init -> void VellumPdf.Reader.PdfReaderOptions.Password.get -> string? VellumPdf.Reader.PdfReaderOptions.Password.init -> void VellumPdf.Reader.PdfReaderOptions.PdfReaderOptions() -> void diff --git a/src/VellumPdf.Reader/README.md b/src/VellumPdf.Reader/README.md index fd5544d..ab4f715 100644 --- a/src/VellumPdf.Reader/README.md +++ b/src/VellumPdf.Reader/README.md @@ -15,8 +15,9 @@ The PDF reader of **[VellumPdf](https://github.com/Tim81/VellumPDF)**, a depende is encrypted rather than guessing at a key. `PdfDocumentReader.WasReconstructed` reports whether a given document took this path. - Tighten-only resource limits (`PdfReaderOptions.MaxDecodedStreamBytes`, - `ReconstructionBudgetMultiplier`) for a caller hardening against a decompression bomb or a file - engineered to burn CPU — both can only lower the shipped default, never raise it. + `ReconstructionBudgetMultiplier`, `MaxDiagnostics`, `MaxFormXObjectDepth`) for a caller hardening + against a decompression bomb, a file engineered to burn CPU, or a deeply nested Form XObject + graph: each can only lower the shipped default, never raise it. - Exposes the document catalog and digital signatures. Stream decoding is internal for now: the public surface reads structure and signatures, not page content — see the roadmap below. - Writes a decrypted copy of an encrypted document (`PdfDocumentReader.SaveDecrypted`), refusing a diff --git a/src/VellumPdf.Reader/ReaderLimits.cs b/src/VellumPdf.Reader/ReaderLimits.cs index de0bdda..d56f508 100644 --- a/src/VellumPdf.Reader/ReaderLimits.cs +++ b/src/VellumPdf.Reader/ReaderLimits.cs @@ -30,11 +30,16 @@ namespace VellumPdf.Reader; /// The cap enforces on — /// see . /// +/// +/// The nesting-depth ceiling ContentInterpreter enforces on recursive Form XObject Do +/// invocations (ISO 32000-2 §8.10); see . +/// internal readonly record struct ReaderLimits( long MaxDecodedBytes, long MaxAggregateReconstructionDecodeBytes, int ReconstructionBudgetMultiplier, - int MaxDiagnostics) + int MaxDiagnostics, + int MaxFormXObjectDepth) { /// The processor's own choice of default per-decode ceiling: 512 MiB. internal const long DefaultMaxDecodedBytes = 512L * 1024 * 1024; @@ -63,12 +68,23 @@ internal readonly record struct ReaderLimits( /// internal const int MinMaxDiagnostics = 1; + /// The processor's own choice of default Form XObject recursion depth ceiling: 32. + internal const int DefaultMaxFormXObjectDepth = 32; + + /// + /// The floor a caller may tighten down to: 1 + /// (a Form XObject may still be invoked, but may not itself invoke another one). + /// + internal const int MinMaxFormXObjectDepth = 1; + /// The library's built-in ceilings — what every read used before this option existed. internal static ReaderLimits Defaults { get; } = - new(DefaultMaxDecodedBytes, DefaultMaxDecodedBytes, DefaultReconstructionBudgetMultiplier, DefaultMaxDiagnostics); + new( + DefaultMaxDecodedBytes, DefaultMaxDecodedBytes, DefaultReconstructionBudgetMultiplier, + DefaultMaxDiagnostics, DefaultMaxFormXObjectDepth); /// - /// Validates 's three resource knobs and resolves them into the + /// Validates 's four resource knobs and resolves them into the /// limits threaded through one read. /// /// @@ -77,19 +93,22 @@ internal readonly record struct ReaderLimits( /// practical limits", and Annex C.3 (informative) adds that available memory is "often much less /// in mobile devices than desktop computers" — the ceiling is this processor's own choice, not a /// spec requirement, so , - /// , and - /// are each a safe upper bound a caller may only lower, never raise. A value under the - /// corresponding floor is rejected too: below or + /// , , and + /// are each a safe upper bound a caller may only + /// lower, never raise. A value under the corresponding floor is rejected too: below + /// or /// , an otherwise ordinary document routinely /// fails to decode or reconstruct at all; below every report - /// would turn into a suppression count, disabling the channel entirely. Each of these is a + /// would turn into a suppression count, disabling the channel entirely; below + /// no Form XObject could be entered at all. Each of these is a /// configuration mistake worth surfacing immediately, here, rather than as a confusing /// exception from a different layer downstream. /// /// /// , - /// , or - /// is outside its allowed range. + /// , + /// , or + /// is outside its allowed range. /// internal static ReaderLimits Resolve(PdfReaderOptions options) { @@ -115,6 +134,14 @@ internal static ReaderLimits Resolve(PdfReaderOptions options) $"{nameof(PdfReaderOptions.MaxDiagnostics)} must be between " + $"{MinMaxDiagnostics} and {DefaultMaxDiagnostics}."); - return new ReaderLimits(maxDecodedBytes, maxDecodedBytes, multiplier, maxDiagnostics); + var maxFormXObjectDepth = options.MaxFormXObjectDepth; + if (maxFormXObjectDepth < MinMaxFormXObjectDepth || maxFormXObjectDepth > DefaultMaxFormXObjectDepth) + throw new ArgumentOutOfRangeException( + nameof(PdfReaderOptions.MaxFormXObjectDepth), maxFormXObjectDepth, + $"{nameof(PdfReaderOptions.MaxFormXObjectDepth)} must be between " + + $"{MinMaxFormXObjectDepth} and {DefaultMaxFormXObjectDepth}."); + + return new ReaderLimits( + maxDecodedBytes, maxDecodedBytes, multiplier, maxDiagnostics, maxFormXObjectDepth); } } diff --git a/tests/VellumPdf.Reader.Tests/ContentInterpreterTests.cs b/tests/VellumPdf.Reader.Tests/ContentInterpreterTests.cs new file mode 100644 index 0000000..7433fe0 --- /dev/null +++ b/tests/VellumPdf.Reader.Tests/ContentInterpreterTests.cs @@ -0,0 +1,3670 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using System.IO.Compression; +using System.Reflection; +using System.Text; +using CsCheck; +using VellumPdf.Core; +using VellumPdf.Document; +using VellumPdf.Reader.Content; + +namespace VellumPdf.Reader.Tests; + +/// +/// Exercises the internal content-stream interpreter (#98, part 3): +/// walking a page's /Contents per ISO 32000-2 §7.8.2. Fixtures are hand-built byte strings +/// (the PageTreeTests style) so tests control exact operator sequences, resource shapes, and +/// deliberately malformed constructs a document writer cannot produce. +/// +public sealed class ContentInterpreterTests +{ + // ── Fixture builders ───────────────────────────────────────────────────────────────────────── + + private sealed record Obj(int Num, string Dict, byte[]? Stream = null); + + private static byte[] BuildPdf(int rootObjectNumber, params Obj[] objects) + { + var ms = new MemoryStream(); + void W(string s) => ms.Write(Encoding.ASCII.GetBytes(s)); + + W("%PDF-1.7\n"); + + var maxNum = objects.Max(o => o.Num); + var offsets = new int?[maxNum + 1]; + foreach (var obj in objects.OrderBy(o => o.Num)) + { + offsets[obj.Num] = (int)ms.Position; + if (obj.Stream is null) + { + W($"{obj.Num} 0 obj\n{obj.Dict}\nendobj\n"); + } + else + { + var trimmed = obj.Dict.TrimEnd(); + var withLength = trimmed[..^2].TrimEnd() + $" /Length {obj.Stream.Length} >>"; + W($"{obj.Num} 0 obj\n{withLength}\nstream\n"); + ms.Write(obj.Stream); + W("\nendstream\nendobj\n"); + } + } + + var xrefOffset = (int)ms.Position; + W($"xref\n0 {maxNum + 1}\n"); + W("0000000000 65535 f \n"); + for (var i = 1; i <= maxNum; i++) + { + W(offsets[i] is { } offset + ? $"{offset:D10} 00000 n \n" + : "0000000000 65535 f \n"); + } + W($"trailer\n<< /Size {maxNum + 1} /Root {rootObjectNumber} 0 R >>\n"); + W($"startxref\n{xrefOffset}\n%%EOF\n"); + + return ms.ToArray(); + } + + /// Same layout as , but writes each object's bytes verbatim + /// rather than through the "N 0 obj\n{dict}\nendobj\n" template: needed when a fixture + /// deliberately writes an object whose own header does not parse (#402 round 7). + private static byte[] BuildPdfWithRawObjectBytes( + int rootObjectNumber, params (int Num, byte[] Bytes)[] objects) + { + var ms = new MemoryStream(); + void W(string s) => ms.Write(Encoding.ASCII.GetBytes(s)); + + W("%PDF-1.7\n"); + + var maxNum = objects.Max(o => o.Num); + var offsets = new int?[maxNum + 1]; + foreach (var obj in objects.OrderBy(o => o.Num)) + { + offsets[obj.Num] = (int)ms.Position; + ms.Write(obj.Bytes); + } + + var xrefOffset = (int)ms.Position; + W($"xref\n0 {maxNum + 1}\n"); + W("0000000000 65535 f \n"); + for (var i = 1; i <= maxNum; i++) + { + W(offsets[i] is { } offset + ? $"{offset:D10} 00000 n \n" + : "0000000000 65535 f \n"); + } + W($"trailer\n<< /Size {maxNum + 1} /Root {rootObjectNumber} 0 R >>\n"); + W($"startxref\n{xrefOffset}\n%%EOF\n"); + + return ms.ToArray(); + } + + private static byte[] Flate(byte[] raw) + { + var ms = new MemoryStream(); + using (var z = new ZLibStream(ms, CompressionLevel.Fastest, leaveOpen: true)) + z.Write(raw); + return ms.ToArray(); + } + + /// Builds a one-page document whose page's own content is , + /// with as its (already-formatted) /Resources value and + /// any (fonts, XObjects, ExtGStates, ...) alongside it. + private static byte[] BuildPageDoc( + string content, string resourcesDict = "<< >>", params Obj[] extraObjects) + { + var objs = new List + { + new(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + $"/Resources {resourcesDict} /Contents 4 0 R >>"), + new(4, "<< >>", Encoding.ASCII.GetBytes(content)), + }; + objs.AddRange(extraObjects); + return BuildPdf(1, [.. objs]); + } + + /// Same as , but takes the page's content as raw bytes. + /// Needed whenever a fixture embeds bytes outside the ASCII range, which + /// Encoding.ASCII.GetBytes would otherwise silently replace with '?'. + private static byte[] BuildPageDocRaw( + byte[] content, string resourcesDict = "<< >>", params Obj[] extraObjects) + { + var objs = new List + { + new(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + $"/Resources {resourcesDict} /Contents 4 0 R >>"), + new(4, "<< >>", content), + }; + objs.AddRange(extraObjects); + return BuildPdf(1, [.. objs]); + } + + private sealed class RecordingVisitor : IContentVisitor + { + public List<(string Op, List Operands, int Offset)> Operators { get; } = []; + public List<(PdfDictionary Dict, byte[] Data, int Offset)> InlineImages { get; } = []; + public List<(PdfDictionary Dict, Matrix Matrix, PdfRectangle? BBox, int ObjectNumber, int Offset)> FormBegins { get; } = []; + public List FormEnds { get; } = []; + + public void OnOperator(string operatorName, IReadOnlyList operands, int offset) => + Operators.Add((operatorName, [.. operands], offset)); + + public void OnInlineImage(PdfDictionary dictionary, ReadOnlyMemory data, int offset) => + InlineImages.Add((dictionary, data.ToArray(), offset)); + + public void OnFormBegin( + PdfDictionary formDictionary, Matrix formMatrix, PdfRectangle? boundingBox, int objectNumber, + int offset) => + FormBegins.Add((formDictionary, formMatrix, boundingBox, objectNumber, offset)); + + public void OnFormEnd(int objectNumber) => FormEnds.Add(objectNumber); + } + + /// A copy of the / + /// field values as of one OnOperator call. + /// 's own exit-time reset (#402 round 7) means neither is readable once + /// itself returns, so a test that wants "the state after + /// the page's own last operator" reads it here instead, captured DURING that operator's own + /// callback the way 's own class doc already says both are meant + /// to be read. + private sealed record ContentStateSnapshot( + Matrix Ctm, double CharSpacing, double WordSpacing, double HorizontalScaling, double Leading, + PdfObject? Font, double FontSize, int RenderMode, double Rise, Matrix TextMatrix, + Matrix TextLineMatrix); + + private sealed class StateSnapshotVisitor(ContentInterpreter interpreter) : IContentVisitor + { + public List<(string Op, List Operands, int Offset)> Operators { get; } = []; + + /// The state snapshot as of the MOST RECENT OnOperator call, updated on + /// every one; still if no operator ever reached this visitor. + public ContentStateSnapshot? LastState { get; private set; } + + public void OnOperator(string operatorName, IReadOnlyList operands, int offset) + { + Operators.Add((operatorName, [.. operands], offset)); + var gs = interpreter.GraphicsState; + var ts = interpreter.TextState; + LastState = new ContentStateSnapshot( + gs.Ctm, gs.CharSpacing, gs.WordSpacing, gs.HorizontalScaling, gs.Leading, gs.Font, + gs.FontSize, gs.RenderMode, gs.Rise, ts.TextMatrix, ts.TextLineMatrix); + } + + public void OnInlineImage(PdfDictionary dictionary, ReadOnlyMemory data, int offset) { } + + public void OnFormBegin( + PdfDictionary formDictionary, Matrix formMatrix, PdfRectangle? boundingBox, int objectNumber, + int offset) + { } + + public void OnFormEnd(int objectNumber) { } + } + + private static (PdfDocumentReader Reader, ContentStateSnapshot? State, StateSnapshotVisitor Visitor) + RunAndCaptureFinalState(byte[] pdfBytes) + { + var reader = PdfReader.Open(pdfBytes); + var interpreter = new ContentInterpreter(reader); + var visitor = new StateSnapshotVisitor(interpreter); + interpreter.Run(reader.GetPage(0), visitor); + return (reader, visitor.LastState, visitor); + } + + private static (PdfDocumentReader Reader, ContentInterpreter Interpreter, RecordingVisitor Visitor) Run( + byte[] pdfBytes, PdfReaderOptions? options = null) + { + var reader = PdfReader.Open(pdfBytes, options ?? new PdfReaderOptions()); + var page = reader.GetPage(0); + var interpreter = new ContentInterpreter(reader); + var visitor = new RecordingVisitor(); + interpreter.Run(page, visitor); + return (reader, interpreter, visitor); + } + + // ── Operand types (known-answer) ──────────────────────────────────────────────────────────── + + [Fact] + public void OperandTypes_areParsedWithExactValuesAndShapes() + { + const string content = + "5 w\n" + + "-.5 6. -.5 6. re\n" + + "(foo \\) bar (nested) baz) Tj\n" + + "<48656C6C6F> Tj\n" + + "/Name#20Test ri\n" + + "[3 2] 0 d\n" + + "[(A) -120 (B) [1 2] 5] TJ\n" + + "/Span << /MCID 1 /Foo (bar) >> BDC\n" + + "EMC\n" + // 'sc' (Table 73) takes a variable operand count and forwards it to the visitor + // untouched (#402 round 4: moved off '\"', once '\"' gained its own Table 107 operand + // type check, so this line still pins boolean/null keyword parsing on an operator that + // does not type-check them). + + "true false null sc\n"; + + var (_, _, visitor) = Run(BuildPageDoc(content)); + + var w = visitor.Operators.Single(o => o.Op == "w"); + Assert.Equal(5, ((PdfInteger)w.Operands[0]).Value); + + var re = visitor.Operators.Single(o => o.Op == "re"); + Assert.Equal(-0.5, ((PdfReal)re.Operands[0]).Value); + Assert.Equal(6.0, ((PdfReal)re.Operands[1]).Value); + Assert.Equal(-0.5, ((PdfReal)re.Operands[2]).Value); + Assert.Equal(6.0, ((PdfReal)re.Operands[3]).Value); + + var tjCalls = visitor.Operators.Where(o => o.Op == "Tj").ToList(); + Assert.Equal(2, tjCalls.Count); + var literal = (PdfLiteralString)tjCalls[0].Operands[0]; + Assert.Equal("foo ) bar (nested) baz", Encoding.ASCII.GetString(literal.Bytes.Span)); + var hex = (PdfHexString)tjCalls[1].Operands[0]; + Assert.Equal("Hello", Encoding.ASCII.GetString(hex.Bytes.Span)); + + var ri = visitor.Operators.Single(o => o.Op == "ri"); + Assert.Equal("Name Test", ((PdfName)ri.Operands[0]).Value); + + var d = visitor.Operators.Single(o => o.Op == "d"); + var dashArray = (PdfArray)d.Operands[0]; + Assert.Equal(2, dashArray.Count); + Assert.Equal(3, ((PdfInteger)dashArray[0]).Value); + Assert.Equal(0, ((PdfInteger)d.Operands[1]).Value); + + var tj = visitor.Operators.Single(o => o.Op == "TJ"); + var tjArray = (PdfArray)tj.Operands[0]; + Assert.Equal(5, tjArray.Count); + Assert.Equal("A", Encoding.ASCII.GetString(((PdfLiteralString)tjArray[0]).Bytes.Span)); + Assert.Equal(-120, ((PdfInteger)tjArray[1]).Value); + Assert.Equal("B", Encoding.ASCII.GetString(((PdfLiteralString)tjArray[2]).Bytes.Span)); + var nested = (PdfArray)tjArray[3]; + Assert.Equal(2, nested.Count); + Assert.Equal(1, ((PdfInteger)nested[0]).Value); + Assert.Equal(2, ((PdfInteger)nested[1]).Value); + Assert.Equal(5, ((PdfInteger)tjArray[4]).Value); + + var bdc = visitor.Operators.Single(o => o.Op == "BDC"); + Assert.Equal("Span", ((PdfName)bdc.Operands[0]).Value); + var props = (PdfDictionary)bdc.Operands[1]; + Assert.Equal(1, ((PdfInteger)props.Get(new PdfName("MCID"))!).Value); + + Assert.Contains(visitor.Operators, o => o.Op == "EMC" && o.Operands.Count == 0); + + var sc = visitor.Operators.Single(o => o.Op == "sc"); + Assert.Equal(3, sc.Operands.Count); + Assert.Same(PdfBoolean.True, sc.Operands[0]); + Assert.Same(PdfBoolean.False, sc.Operands[1]); + Assert.Same(PdfNull.Instance, sc.Operands[2]); + } + + [Fact] + public void RealOperandOfTwoMillionDigits_isParsedOnTheHeap_andRunReturns() + { + // TryParseOperandNumber used to size its padding buffer with a raw stackalloc keyed on the + // numeric token's own length. PdfLexer.ReadNumeric puts no bound on that length (it is a + // content-stream operand, not a value this reader's own resource caps ever see), so an + // operand of about 1.5 million digits overflowed the stack outright, an uncatchable crash + // this type's own contract (above) says a malformed or absurd construct must not cause. On + // the heap Utf8Parser still parses this exactly: a two-million-digit literal is conformant, + // if absurd, §7.3.3 syntax, so its value survives rather than falling back to a malformed + // report. This must not run against the pre-fix binary: there it kills the whole test host. + var digits = new string('1', 2_000_000); + var content = "0." + digits + " w\n1 w\n"; + // About 2 MiB of content, far under the default MaxDecodedStreamBytes the Run helper leaves + // in force, so a limit diagnostic cannot be what Assert.Empty below is looking at. + Assert.True(content.Length < ReaderLimits.DefaultMaxDecodedBytes); + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + var wCalls = visitor.Operators.Where(o => o.Op == "w").ToList(); + Assert.Equal(2, wCalls.Count); + var real = (PdfReal)wCalls[0].Operands[0]; + Assert.Equal(0.1111111111, real.Value, 9); + Assert.Equal(1, ((PdfInteger)wCalls[1].Operands[0]).Value); + Assert.Empty(reader.Diagnostics); + } + + // ── BX/EX compatibility sections ──────────────────────────────────────────────────────────── + + [Fact] + public void UnknownOperator_outsideBX_isReportedOncePerPage_andInsideBX_isSilent() + { + // Two distinct unknown names outside BX/EX: the sink dedupes on (code, object, page), so + // the page records the first one only. + const string content = "Zork\nZork\nBlat\nBX\nZork\n{ pop }\n> \nEX\n"; + + var (reader, _, _) = Run(BuildPageDoc(content)); + + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.UnknownOperator).ToList(); + Assert.Single(reports); + Assert.Equal(PdfReaderDiagnosticSeverity.Warning, reports[0].Severity); + Assert.Contains("'Zork'", reports[0].Message); + } + + [Fact] + public void CurlyBracesAndLoneGreaterThan_insideBX_doNotAbortThePage() + { + const string content = "BX\n{ pop } \n> \nEX\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void UnknownOperatorInsideBX_dropsItsOperands_perTable33() + { + // Table 33: "Unrecognised operators (along with their operands) shall be ignored without + // error until the balancing EX operator is encountered" (#402). Before the fix, the + // unknown-operator branch never cleared the operand stack regardless of _bxDepth, so the + // 1 2 3 preceding SomeFutureOp survived to be misread as w's own operand. + const string content = "BX\n1 2 3 SomeFutureOp\n5 w\nEX\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Equal(["BX", "w", "EX"], visitor.Operators.Select(o => o.Op)); + var w = visitor.Operators.Single(o => o.Op == "w"); + Assert.Equal(5, ((PdfInteger)w.Operands[0]).Value); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.UnknownOperator); + } + + [Fact] + public void UnknownOperatorOutsideBX_alsoDropsItsOperands() + { + // §7.8.2: "operands shall not be left over when an operator finishes execution" applies to + // an unrecognised keyword's own (no-op) execution too, not only to a recognised one + // (#402 round 2). Before the fix, the operands preceding an unrecognised operator outside a + // BX/EX section survived to be misread by whatever operator followed: '20' here was fed + // into 'w' as its own operand instead of the '1' that belongs to it. + const string content = "10 20 Zork\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Equal(["w"], visitor.Operators.Select(o => o.Op)); + var w = Assert.Single(visitor.Operators); + Assert.Equal(1, ((PdfInteger)w.Operands[0]).Value); + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.UnknownOperator); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + } + + [Fact] + public void BxEx_ownArityIsChecked_butTheSectionStillOpensAndCloses() + { + // BX and EX are both arity-0 (Table 33), but used to be dispatched before the generic + // arity check ran at all, so leftover operands on either one reported nothing even though + // _arity["BX"] and _arity["EX"] are both 0 (#402 round 3). The fix runs the same arity + // check on BX/EX as every other operator, but unlike an ordinary mismatch (which drops the + // whole call), a compatibility section still opens or closes regardless of what garbage + // operands preceded BX/EX: Table 33 does not condition that on anything. Two pages, not + // one: the sink's dedupe key is (code, object, page), so a malformed BX and a malformed EX + // on the SAME page/object would collapse to one recorded diagnostic even though both + // independently fire and independently clear their own operands (see + // UnknownOperatorInsideBX for the same dedupe behaviour on a different code); splitting + // them across two pages is what makes both individually observable in reader.Diagnostics. + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>"), + new Obj(4, "<< >>", "1 2 3 BX\nEX\nQ\n"u8.ToArray()), + new Obj(5, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 6 0 R >>"), + new Obj(6, "<< >>", "BX\n4 EX\nQ\n"u8.ToArray())); + + var reader = PdfReader.Open(doc, new PdfReaderOptions()); + var interpreter = new ContentInterpreter(reader); + var visitor0 = new RecordingVisitor(); + var visitor1 = new RecordingVisitor(); + interpreter.Run(reader.GetPage(0), visitor0); + interpreter.Run(reader.GetPage(1), visitor1); + + var mismatches = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed) + .ToList(); + Assert.Equal(2, mismatches.Count); + Assert.Contains(mismatches, d => d.Message.Contains("'BX'", StringComparison.Ordinal)); + Assert.Contains(mismatches, d => d.Message.Contains("'EX'", StringComparison.Ordinal)); + // Both sections still close on their own page: a form invoked with the SAME unknown + // operator inside it would report UnknownOperator if EX had failed to close the section + // (see UnknownOperatorInsideBX above for the positive case); here Q, well outside BX/EX by + // the time it runs on each page, still reaches the visitor as an ordinary operator. + Assert.Contains(visitor0.Operators, o => o.Op == "Q"); + Assert.Contains(visitor1.Operators, o => o.Op == "Q"); + } + + // ── Operand-stack and graphics-state caps ─────────────────────────────────────────────────── + + [Fact] + public void OperandStackCap_64IsOk_65IsALimitNotAMalformation() + { + // 64 numeric operands, none consumed by an operator (so this pins the CAP itself, not any + // one operator's own arity): the 64th push must not itself overflow. 64, not 32: + // Table 73's scn operator can legally take 33+ operands for a DeviceN space with many + // colourants (see the cap's own comment in ContentInterpreter), so 32 rejected a legal call. + var okContent = string.Join(' ', Enumerable.Repeat("1", 64)); + var overContent = string.Join(' ', Enumerable.Repeat("1", 65)); + + var (okReader, _, _) = Run(BuildPageDoc(okContent)); + Assert.DoesNotContain(okReader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + + var (overReader, _, _) = Run(BuildPageDoc(overContent)); + // This reader's own ceiling, not a producer-side malformation: ContentLimitExceeded (#402), + // not OperandStackMalformed. + Assert.Contains(overReader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.DoesNotContain(overReader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + } + + [Fact] + public void TjArrayCap_8192IsOk_8193IsALimitNotAMalformation() + { + var okArray = "[" + string.Concat(Enumerable.Repeat("0 ", 8192)) + "] TJ\n"; + var overArray = "[" + string.Concat(Enumerable.Repeat("0 ", 8193)) + "] TJ\n"; + + var (okReader, _, okVisitor) = Run(BuildPageDoc(okArray)); + Assert.DoesNotContain(okReader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Single(okVisitor.Operators, o => o.Op == "TJ"); + + var (overReader, _, overVisitor) = Run(BuildPageDoc(overArray)); + Assert.Contains(overReader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.DoesNotContain(overReader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + Assert.DoesNotContain(overVisitor.Operators, o => o.Op == "TJ"); + } + + [Fact] + public void TjOperand_notAnArray_reportsOperandStackMalformed_notALimit() + { + // A wrong TYPE (a producer-side malformation) must stay OperandStackMalformed even though + // the ELEMENT-COUNT cap right beside it in the same switch case moved to ContentLimitExceeded. + var (reader, _, visitor) = Run(BuildPageDoc("5 TJ\n1 w\n")); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.DoesNotContain(visitor.Operators, o => o.Op == "TJ"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void HugeArrayOperand_capReportsBeforeMaterialisingIt_boundingAllocation() + { + // An array operand used to be fully materialised as a PdfArray, with one boxed PdfInteger + // per element, before the 8192-element cap was ever consulted (:737-745 against :491-494 at + // 5f5f84b): a 20,000,000-element '[1 1 1 ...] TJ', source text well within the 64 MiB + // content budget, allocated 1,784 MiB and committed 977 MiB before reporting one 309. + // CompositeOperandWithinCap decides the cap with the lexer alone (#402 round 3), so this + // array is never materialised at all. Built unfiltered (BuildPageDocRaw), not through + // FlateDecode: decompressing a ~38 MiB result into a growable buffer is itself a sizeable + // allocation this reader's own filter pipeline pays regardless of this fix, and folding it + // into the same measurement would bound the WRONG thing (measured: 204.7 MiB total with + // FlateDecode in the mix, most of it decompression, none of it this fix's own concern). + const int elementCount = 20_000_000; + var unit = "1 "u8.ToArray(); + var arrayBody = new byte[unit.Length * elementCount]; + for (var i = 0; i < elementCount; i++) + unit.CopyTo(arrayBody, i * unit.Length); + var content = "["u8.ToArray().Concat(arrayBody).Concat("] TJ\n1 w\n"u8.ToArray()).ToArray(); + var doc = BuildPageDocRaw(content); + + // Measured per thread, not process-wide: the test host runs classes in parallel, and the + // process-wide counter charged this delta with whatever the other classes allocated at the + // same moment (1125.1 MiB and 373.1 MiB on the CI runner for a shape that costs 76.3 MiB + // alone). Run interprets synchronously on the calling thread, so the thread-local counter + // sees exactly this call's allocations. + var before = GC.GetAllocatedBytesForCurrentThread(); + var (reader, _, visitor) = Run(doc); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Equal(["w"], visitor.Operators.Select(o => o.Op)); + // An allocation bound, not a wall-clock one (#400): generous by design, not tight. 96 MiB + // is comfortably above the ~38 MiB source text this reader still reads and lexes, and far + // below the 1,784 MiB the pre-fix path allocated for the identical shape. + Assert.True( + allocated < 96L * 1024 * 1024, + $"expected under 96 MiB allocated for the capped array; measured " + + $"{allocated / (1024.0 * 1024.0):F1} MiB."); + } + + [Fact] + public void HugeNestedArrayOperand_countsEveryDepth_boundingAllocation() + { + // The first version of CompositeOperandWithinCap counted depth-1 tokens only, so + // '[[1 1 1 ...]] TJ' counted as ONE element, passed the cap, and was materialised in full: + // the same 1,784 MiB shape as the flat array above, hidden behind a single pair of + // brackets. Every token at every depth counts now. + const int elementCount = 20_000_000; + var unit = "1 "u8.ToArray(); + var arrayBody = new byte[unit.Length * elementCount]; + for (var i = 0; i < elementCount; i++) + unit.CopyTo(arrayBody, i * unit.Length); + var content = "[["u8.ToArray().Concat(arrayBody).Concat("]] TJ\n1 w\n"u8.ToArray()).ToArray(); + var doc = BuildPageDocRaw(content); + + var before = GC.GetAllocatedBytesForCurrentThread(); + var (reader, _, visitor) = Run(doc); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Equal(["w"], visitor.Operators.Select(o => o.Op)); + Assert.True( + allocated < 96L * 1024 * 1024, + $"expected under 96 MiB allocated for the capped nested array; measured " + + $"{allocated / (1024.0 * 1024.0):F1} MiB."); + } + + [Fact] + public void HugeUnterminatedArrayOperand_isDroppedAsOverCap_notMaterialisedBeforeTheLexError() + { + // An unterminated composite used to return "within cap" from the pre-scan so that + // ParseObject could re-derive the ContentStreamLexError, which it did, but only after + // allocating every element up to the point of failure. Over the cap, the composite is now + // dropped with one 309 and never parsed; the lexer is left at end of input, so no 300 + // follows and nothing else is reported. + const int elementCount = 20_000_000; + var unit = "1 "u8.ToArray(); + var arrayBody = new byte[unit.Length * elementCount]; + for (var i = 0; i < elementCount; i++) + unit.CopyTo(arrayBody, i * unit.Length); + var content = "1 w\n["u8.ToArray().Concat(arrayBody).ToArray(); + var doc = BuildPageDocRaw(content); + + var before = GC.GetAllocatedBytesForCurrentThread(); + var (reader, _, visitor) = Run(doc); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Equal(["w"], visitor.Operators.Select(o => o.Op)); + Assert.True( + allocated < 96L * 1024 * 1024, + $"expected under 96 MiB allocated for the unterminated array; measured " + + $"{allocated / (1024.0 * 1024.0):F1} MiB."); + } + + [Fact] + public void SmallUnterminatedArrayOperand_stillReportsTheLexError_notTheCap() + { + // Within the cap, an unterminated composite keeps its original outcome: ParseObject + // re-derives the failure and the stream ends with ContentStreamLexError, not 309. + var (reader, _, visitor) = Run(BuildPageDoc("1 w\n[1 2 3\n")); + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Equal(["w"], visitor.Operators.Select(o => o.Op)); + } + + [Fact] + public void OverCapArrayOperand_withAMalformedTokenInside_reportsTheLexErrorAlongsideTheCap() + { + // 9000 elements is over MaxCompositeOperandElements (8192), and the unterminated string + // right after them is its own lex failure (no closing ')' anywhere in the rest of the + // buffer), not merely the count pass bailing out at the cap. Before this fix, the count + // pass's own catch swallowed that failure silently: only 309 was reported, with nothing to + // explain why interpretation also stopped dead right there, even though the identical + // failure UNDER the cap (see the next test) already reported 300 through ParseObject's own + // re-parse (#402 round 4). + var content = "[" + string.Concat(Enumerable.Repeat("1 ", 9000)) + "(unterminated\n] TJ\n1 w\n"; + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Empty(visitor.Operators); + } + + [Fact] + public void UnderCapArrayOperand_withAMalformedTokenInside_reportsOnlyTheLexError() + { + // Same malformed string, well under the cap: ParseObject's own re-parse already derives + // this as ContentStreamLexError, with no ContentLimitExceeded at all, since the count pass + // never came near the cap. + const string content = "[1 1 (unterminated\n] TJ\n2 w\n"; + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Empty(visitor.Operators); + } + + [Fact] + public void UnbalancedQ_isIgnoredWithADiagnostic() + { + var (reader, _, visitor) = Run(BuildPageDoc("Q\n1 w\n")); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + Assert.Contains(visitor.Operators, o => o.Op == "w"); // interpretation continued + } + + // ── Over-cap 'q'/'BMC' pushes must not desync a later balanced pop (#402 round 2) ───────────── + + [Theory] + [InlineData(65)] + [InlineData(70)] + public void BalancedQAndQ_pastTheGraphicsStateCap_reportsOnlyTheLimit_andStaysBalanced(int depth) + { + // Before the fix, the pushes past MaxGraphicsStateDepth (64) were dropped but their + // matching 'Q's still popped anyway, so a balanced 'q'...'Q' sequence deeper than the cap + // reported BOTH ContentLimitExceeded (for the dropped pushes) AND OperandStackMalformed + // (for the "Q"s that found nothing left on the stack once the producer's own pushes were + // exhausted), and desynchronised GraphicsState.Ctm from the actual nesting besides. + var content = string.Concat(Enumerable.Repeat("q\n", depth)) + + "2 0 0 2 10 20 cm\n" + + string.Concat(Enumerable.Repeat("Q\n", depth)); + + var (reader, state, _) = RunAndCaptureFinalState(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + // Every 'q' was balanced by a 'Q', so the CTM the 'cm' set inside is restored to identity, + // whether or not this reader's own stack held every one of those saves. The LAST 'Q' is + // what the snapshot reflects, so this is exactly the state the page itself ends on. + Assert.Equal(Matrix.Identity, state!.Ctm); + } + + [Fact] + public void BalancedBmcAndEmc_pastTheMarkedContentCap_reportsOnlyTheLimit_andStaysBalanced() + { + const int depth = 65; + var content = string.Concat(Enumerable.Repeat("/Tag BMC\n", depth)) + + string.Concat(Enumerable.Repeat("EMC\n", depth)) + + "1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + // ── A push dropped for one of this reader's own ceilings (not the depth cap above, which + // already credits its own drops) still credits its matching pop, so 'EMC'/'Q' does not report + // an unbalanced pop purely because this reader declined to push the state it was asked to + // (#402 round 4) ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void BdcDroppedForAnOverCapPropertiesDictionary_creditsTheMatchingEmc() + { + // §14.6.2 puts no size bound on a property list. 5000 key/value pairs is 10,000 tokens, + // over MaxCompositeOperandElements (8192), so BDC's own dictionary operand is dropped and + // BDC itself never reaches the visitor; before this fix, the balancing EMC then reported an + // unbalanced pop on top of the 309, even though the document itself is conformant. + var props = string.Concat(Enumerable.Range(0, 5000).Select(i => $"/K{i} {i} ")); + var content = $"/Tag << {props}>> BDC\nEMC\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + Assert.DoesNotContain(visitor.Operators, o => o.Op == "BDC"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void QDroppedForTheOperandCountCap_creditsTheMatchingQ() + { + // 65 numeric operands ahead of 'q' overflow MaxOperandsPerOperator (64), so 'q' itself is + // dropped for the SAME reason PushOperand already reported (no second diagnostic); the + // matching 'Q' must still consume that credit silently rather than report an unbalanced pop. + var content = string.Join(' ', Enumerable.Repeat("1", 65)) + " q\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + Assert.DoesNotContain(visitor.Operators, o => o.Op == "q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void QDroppedForTheOperandCountCap_creditsOnlyOneQ_aSecondQStillReportsUnbalanced() + { + // Only ONE 'q' was dropped, so only one credit exists: a second 'Q' past it finds nothing + // pushed and no credit left, and reports the ordinary unbalanced-pop diagnostic. This test + // guards over-crediting rather than pinning the round-4 fix itself (the two sibling tests + // do that): a credit of 2 for the one dropped 'q' would silence both 'Q's, leaving + // `unbalanced` empty and failing Assert.Single below. + var content = string.Join(' ', Enumerable.Repeat("1", 65)) + " q\nQ\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + var unbalanced = reader.Diagnostics + .Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed) + .ToList(); + Assert.Single(unbalanced); + Assert.Contains("'Q'", unbalanced[0].Message, StringComparison.Ordinal); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + // ── q/Q/cm matrix state, text state ────────────────────────────────────────────────────────── + + [Fact] + public void GraphicsStateStack_savesAndRestoresTheCtm() + { + var (_, state, _) = RunAndCaptureFinalState(BuildPageDoc("q\n2 0 0 2 10 20 cm\nQ\n")); + + Assert.Equal(Matrix.Identity, state!.Ctm); + } + + [Fact] + public void Cm_concatenatesOntoTheCurrentCtm() + { + var (_, state, _) = RunAndCaptureFinalState(BuildPageDoc("2 0 0 2 10 20 cm\n")); + + Assert.Equal(new Matrix(2, 0, 0, 2, 10, 20), state!.Ctm); + } + + [Fact] + public void TextStateOperators_setTheExpectedFields() + { + const string content = + "1 Tc 2 Tw 150 Tz 12 TL /F1 24 Tf 2 Tr 3 Ts\n" + + "10 20 Td\n" + + "5 -6 TD\n" + + "1 0 0 1 100 200 Tm\n" + + "T*\n"; + var (_, state, _) = RunAndCaptureFinalState( + BuildPageDoc(content, "<< /Font << /F1 5 0 R >> >>", + new Obj(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"))); + + Assert.Equal(1, state!.CharSpacing); + Assert.Equal(2, state.WordSpacing); + Assert.Equal(150, state.HorizontalScaling); + Assert.Equal(6, state.Leading); // TD's ty=-6 sets TL=-(-6)=6 + Assert.Equal("F1", ((PdfName)state.Font!).Value); + Assert.Equal(24, state.FontSize); + Assert.Equal(2, state.RenderMode); + Assert.Equal(3, state.Rise); + + // After Tm replaces both matrices with [1 0 0 1 100 200], T* moves by (0, -TL=-6): + // Tlm_new = [1 0 0 1 0 -6] x [1 0 0 1 100 200] = [1 0 0 1 100 194]. + Assert.Equal(new Matrix(1, 0, 0, 1, 100, 194), state.TextMatrix); + } + + [Fact] + public void BT_resetsTheTextMatrices() + { + const string content = "1 0 0 1 100 200 Tm\nBT\n"; + var (_, state, _) = RunAndCaptureFinalState(BuildPageDoc(content)); + + Assert.Equal(Matrix.Identity, state!.TextMatrix); + Assert.Equal(Matrix.Identity, state.TextLineMatrix); + } + + // ── An operand of the wrong type at the right arity reports 302 and drops the operator, + // rather than NumberOperand silently substituting 0 or Do silently no-op'ing (#402 round 3) ─── + + [Fact] + public void Cm_withANonNumericOperand_reportsOnce_andLeavesTheCtmUntouched() + { + // Before this fix, NumberOperand substituted 0 for the string operand with no diagnostic, + // so this delivered Ctm = [1 0 0 1 0 50] instead of leaving the identity CTM untouched. + // 'cm' itself never reaches the visitor (the type check drops it before EmitAndClear), so + // the state snapshot below is taken at the trailing 'w' instead. + var (reader, state, _) = RunAndCaptureFinalState(BuildPageDoc("1 0 0 1 (x) 50 cm\n1 w\n")); + + Assert.Equal(Matrix.Identity, state!.Ctm); + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed) + .ToList(); + Assert.Single(reports); + Assert.Contains("'cm'", reports[0].Message, StringComparison.Ordinal); + } + + [Fact] + public void TfWithAStringSizeAndTdWithANameOperand_bothDropped_stateUntouched() + { + // Before this fix: Tf's non-numeric size operand ("(12)", a string) silently coerced to a + // FontSize of 0 (already the default, so this alone would not have been visible), but Td's + // non-numeric ty operand ("/N", a name) coerced to 0 while tx=10 was still applied, + // delivering TextMatrix = [1 0 0 1 10 0] instead of leaving it at the identity. Both Tf and + // Td are dropped entirely now; only one 302 is recorded, since the sink dedupes on (code, + // object, page) and both reports share the same key. 'ET' is the last operator to reach + // the visitor (Tf and Td are both dropped), so the snapshot at 'ET' is exactly the state + // this page ends on. + var (reader, state, _) = RunAndCaptureFinalState( + BuildPageDoc( + "BT\n/F1 (12) Tf\n10 /N Td\nET\n", "<< /Font << /F1 5 0 R >> >>", + new Obj(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"))); + + Assert.Null(state!.Font); + Assert.Equal(0, state.FontSize); + Assert.Equal(Matrix.Identity, state.TextMatrix); + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed) + .ToList(); + Assert.Single(reports); + } + + [Fact] + public void Do_withANonNameOperand_reportsOnce_andNeverReachesTheVisitor() + { + // Before this fix, a non-name operand to Do silently no-op'd: EmitAndClear had already + // reported "Do" to the visitor before the (missing) type check would have run, and neither + // 302 nor 306 was ever reported. Do is now type-checked BEFORE it is emitted, so a + // malformed invocation like this never reaches the visitor at all. + var content = "42 Do\n"; + var doc = BuildPageDoc( + content, "<< /XObject << /F1 5 0 R >> >>", + new Obj(5, "<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] >>", "1 w\n"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(visitor.Operators, o => o.Op == "Do"); + Assert.Empty(visitor.FormBegins); + Assert.Contains( + reader.Diagnostics, + d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed + && d.Message.Contains("'Do'", StringComparison.Ordinal)); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + } + + // ── ' and " (Table 107 line-showing operators, #402 round 4) ─────────────────────────────────── + + [Fact] + public void Quote_movesToTheNextLine_andForwardsTheStringOperandUntouched() + { + // Table 107: "' ... shall have the same effect as the code T* string Tj". Before this fix, + // ' fell through to the default (state-inert) case: the text matrices were left untouched + // and the string operand still reached the visitor, since forwarding happens either way. + const string content = "BT\n100 TL\n5 0 Td\n(a) '\nET\n"; + var (reader, state, visitor) = RunAndCaptureFinalState(BuildPageDoc(content)); + + // Td moves the line matrix to [1 0 0 1 5 0]; ' then moves by (0, -TL=-100): + // Tlm_new = [1 0 0 1 0 -100] x [1 0 0 1 5 0] = [1 0 0 1 5 -100]. 'ET' follows and does not + // disturb either matrix, so the snapshot at 'ET' (the last operator) still reflects it. + Assert.Equal(new Matrix(1, 0, 0, 1, 5, -100), state!.TextLineMatrix); + Assert.Equal(new Matrix(1, 0, 0, 1, 5, -100), state.TextMatrix); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + + var quote = visitor.Operators.Single(o => o.Op == "'"); + var str = Assert.Single(quote.Operands); + Assert.Equal("a", Encoding.ASCII.GetString(((PdfLiteralString)str).Bytes.Span)); + } + + [Fact] + public void DoubleQuote_setsWordAndCharSpacing_thenMovesToTheNextLine() + { + // Table 107: "\" ... shall have the same effect as this code: aw Tw ac Tc string '": aw and + // ac land in the text state before the T*-equivalent move. + const string content = "BT\n50 TL\n7 8 (x) \"\nET\n"; + var (reader, state, visitor) = RunAndCaptureFinalState(BuildPageDoc(content)); + + Assert.Equal(7, state!.WordSpacing); + Assert.Equal(8, state.CharSpacing); + // TextLineMatrix starts at identity (no Td/Tm ran); " moves by (0, -TL=-50): + // Tlm_new = [1 0 0 1 0 -50] x [1 0 0 1 0 0] = [1 0 0 1 0 -50]. + Assert.Equal(new Matrix(1, 0, 0, 1, 0, -50), state.TextLineMatrix); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + + var quote = visitor.Operators.Single(o => o.Op == "\""); + Assert.Equal(3, quote.Operands.Count); + } + + [Fact] + public void DoubleQuote_withNonNumericSpacingOperands_reportsOnce_andIsDropped() + { + const string content = "BT\n(a) (b) (x) \"\nET\n"; + var (reader, state, visitor) = RunAndCaptureFinalState(BuildPageDoc(content)); + + Assert.DoesNotContain(visitor.Operators, o => o.Op == "\""); + Assert.Equal(0, state!.WordSpacing); + Assert.Equal(0, state.CharSpacing); + Assert.Equal(Matrix.Identity, state.TextLineMatrix); + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed) + .ToList(); + Assert.Single(reports); + } + + [Fact] + public void Quote_withANonStringOperand_reportsOnce_andIsDropped() + { + const string content = "BT\n5 '\nET\n"; + var (reader, state, visitor) = RunAndCaptureFinalState(BuildPageDoc(content)); + + Assert.DoesNotContain(visitor.Operators, o => o.Op == "'"); + Assert.Equal(Matrix.Identity, state!.TextLineMatrix); + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed) + .ToList(); + Assert.Single(reports); + } + + // ── gs with /Font ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Gs_withFont_surfacesTheFontSelectionToTheState() + { + var (_, state, _) = RunAndCaptureFinalState( + BuildPageDoc( + "/G1 gs\n", + "<< /ExtGState << /G1 6 0 R >> >>", + new Obj(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"), + new Obj(6, "<< /Type /ExtGState /Font [5 0 R 18] >>"))); + + var fontRef = Assert.IsType(state!.Font); + Assert.Equal(5, fontRef.ObjectNumber); + Assert.Equal(18, state.FontSize); + } + + [Fact] + public void Gs_namingAMissingExtGState_reportsResourceMissing() + { + var (reader, _, _) = Run(BuildPageDoc("/Absent gs\n", "<< /ExtGState << >> >>")); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + } + + // ── gs/cs/CS/sh read their own operand for a resource lookup, so a wrong type reports 302 the + // same way Do's own does, rather than the lookup silently no-op'ing (#402 round 4) ──────────── + + [Theory] + [InlineData("12 gs\n")] + [InlineData("5 sh\n")] + [InlineData("(x) cs\n")] + [InlineData("7 CS\n")] + public void ResourceLookupOperator_withANonNameOperand_reportsOnce_andNeverReachesTheVisitor( + string content) + { + // Before this fix: HandleExtGState/ValidateColorSpaceResource/ValidateNamedResource each + // read _operands[0] as a name unchecked, so a numeric or string operand there silently + // no-op'd the resource lookup with no diagnostic at all, the same gap Do had before round 3. + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Empty(visitor.Operators); + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed) + .ToList(); + Assert.Single(reports); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + } + + // ── Form XObjects ──────────────────────────────────────────────────────────────────────────── + + private static Obj[] BuildFormChain(int count, string leafExtra = "") + { + // Form N invokes Form N+1; Form `count` is the one final recursion is expected to skip. + var objs = new List(); + for (var i = 1; i <= count; i++) + { + var body = i < count ? $"/F{i + 1} Do" : leafExtra; + var resources = i < count + ? $"<< /XObject << /F{i + 1} {11 + i} 0 R >> >>" + : "<< >>"; + objs.Add(new Obj( + 10 + i, + $"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources {resources} >>", + Encoding.ASCII.GetBytes(body))); + } + return [.. objs]; + } + + [Fact] + public void NestedForms_toDepth32_ok_33_reportsDepthExceeded() + { + var forms = BuildFormChain(33); + var doc = BuildPageDoc("/F1 Do\n", "<< /XObject << /F1 11 0 R >> >>", forms); + + var (reader, _, visitor) = Run(doc); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.FormXObjectDepthExceeded); + Assert.Equal(33, visitor.Operators.Count(o => o.Op == "Do")); + Assert.Equal(32, visitor.FormBegins.Count); + Assert.Equal(32, visitor.FormEnds.Count); + } + + [Fact] + public void NestedForms_atDepth32_reportsNoDepthExceeded() + { + var forms = BuildFormChain(32); + var doc = BuildPageDoc("/F1 Do\n", "<< /XObject << /F1 11 0 R >> >>", forms); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.FormXObjectDepthExceeded); + Assert.Equal(32, visitor.FormBegins.Count); + } + + [Fact] + public void SelfReferencingForm_reportsCycleOnce() + { + var doc = BuildPageDoc( + "/F1 Do\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] " + + "/Resources << /XObject << /F1 11 0 R >> >> >>", + "/F1 Do"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + var cycles = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.FormXObjectCycle).ToList(); + Assert.Single(cycles); + Assert.Single(visitor.FormBegins); + Assert.Single(visitor.FormEnds); + } + + [Fact] + public void SelfReferencingForm_withMaxDepthOne_stillReportsCycle_notDepthExceeded() + { + // With MaxFormXObjectDepth this tight, the outer 'Do' (page -> Form11, depth 0 -> 1) + // succeeds, so Form11's own self-'Do' hits _formDepth == the cap on the very same + // invocation that also closes the cycle. Before the fix, the depth check ran BEFORE the + // cycle check, so this reported FormXObjectDepthExceeded (technically true, but strictly + // less informative than the actual reason: the recursion is not merely deep, it never + // terminates at all) (#402 round 2). + var doc = BuildPageDoc( + "/F1 Do\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] " + + "/Resources << /XObject << /F1 11 0 R >> >> >>", + "/F1 Do"u8.ToArray())); + + var (reader, _, _) = Run(doc, new PdfReaderOptions { MaxFormXObjectDepth = 1 }); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.FormXObjectCycle); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.FormXObjectDepthExceeded); + } + + [Fact] + public void FormDrawn4097Times_reportsBudgetExceeded_andThePageStillCompletes() + { + var pageContent = string.Concat(Enumerable.Repeat("/F1 Do\n", 4097)) + "1 w\n"; + var doc = BuildPageDoc( + pageContent, "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", [])); + + var (reader, _, visitor) = Run(doc); + + var budget = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.FormXObjectBudgetExceeded).ToList(); + Assert.Single(budget); + Assert.Equal(4097, visitor.Operators.Count(o => o.Op == "Do")); + Assert.True(visitor.FormBegins.Count <= 4096); + Assert.Contains(visitor.Operators, o => o.Op == "w"); // the page itself still finished + } + + [Fact] + public void Form_withoutOwnResources_fallsBackToTheParentsResources() + { + var doc = BuildPageDoc( + "/F1 Do\n", "<< /Shading << /Sh1 20 0 R >> /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", "/Sh1 sh"u8.ToArray()), + new Obj(20, "<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 1 1] >>")); + + var (reader, _, _) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + } + + [Fact] + public void Form_matrixAndBBox_areHandedToTheVisitor() + { + var doc = BuildPageDoc( + "/F1 Do\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, + "<< /Type /XObject /Subtype /Form /BBox [1 2 3 4] /Matrix [2 0 0 2 5 6] >>", + [])); + + var (_, _, visitor) = Run(doc); + + var begin = Assert.Single(visitor.FormBegins); + Assert.Equal(new Matrix(2, 0, 0, 2, 5, 6), begin.Matrix); + Assert.NotNull(begin.BBox); + Assert.Equal(1, begin.BBox!.LlX); + Assert.Equal(2, begin.BBox.LlY); + Assert.Equal(3, begin.BBox.UrX); + Assert.Equal(4, begin.BBox.UrY); + Assert.Equal(11, begin.ObjectNumber); + } + + [Fact] + public void Form_matrixAndBBox_resolveThroughAnIndirectReference() + { + // §7.3.10 lets any dictionary entry be given as an indirect reference; Table 93 gives + // /Matrix and /BBox no direct-only restriction, so both must resolve before the shape + // check ContentInterpreter runs against them (#402). + var doc = BuildPageDoc( + "/F1 Do\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox 12 0 R /Matrix 13 0 R >>", []), + new Obj(12, "[1 2 3 4]"), + new Obj(13, "[2 0 0 2 5 6]")); + + var (_, _, visitor) = Run(doc); + + var begin = Assert.Single(visitor.FormBegins); + Assert.Equal(new Matrix(2, 0, 0, 2, 5, 6), begin.Matrix); + Assert.NotNull(begin.BBox); + Assert.Equal(1, begin.BBox!.LlX); + Assert.Equal(4, begin.BBox.UrY); + } + + [Fact] + public void FormInvokedInsideBX_startsItsOwnCompatibilitySection_notInheritedFromTheInvoker() + { + // Table 33 scopes a BX/EX compatibility section to ONE content stream; a form is its own + // content stream. Before this fix, HandleDo saved and restored _bxDepth but never RESET it + // for the form's own scope, so a form invoked from inside 'BX ... Do ... EX' inherited the + // invoker's _bxDepth > 0, making the form's own unknown operator look like it was still + // inside the INVOKER's compatibility section and silently swallowing it, even though the + // same form invoked as a bare 'Do' correctly reports it (#402 round 3). + var doc = BuildPageDoc( + "BX\n/F1 Do\nEX\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", "10 20 Zork\n1 w\n"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.UnknownOperator).ToList(); + Assert.Single(reports); + Assert.Contains("'Zork'", reports[0].Message, StringComparison.Ordinal); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + // ── §8.10.1 b): the form's own /Matrix is concatenated into the CTM (#402 round 2) ─────────── + + [Fact] + public void Do_concatenatesTheFormsOwnMatrixIntoTheCtm_forOperatorsInsideTheForm() + { + var doc = BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Matrix [2 0 0 2 10 10] >>", + "0 0 1 1 re"u8.ToArray())); + + Matrix? ctmInsideForm = null; + Matrix? ctmAfterForm = null; + var reader = PdfReader.Open(doc); + var interpreter = new ContentInterpreter(reader); + // Both captured DURING their own callback (#402 round 7: Run's own exit-time reset means + // GraphicsState is not readable once Run itself returns), not just the inside-the-form one: + // the invoker's own restored CTM is read from 'w', the operator right after 'Do' returns, + // rather than from the interpreter after Run. + var probe = new CtmProbeVisitor( + () => ctmInsideForm ??= interpreter.GraphicsState.Ctm, + () => ctmAfterForm ??= interpreter.GraphicsState.Ctm); + interpreter.Run(reader.GetPage(0), probe); + + // §8.3.4: CTM_new = M x CTM_old; the invoker's own CTM at the point of Do is identity, so + // the composed value equals the form's own /Matrix exactly. + Assert.Equal(new Matrix(2, 0, 0, 2, 10, 10), ctmInsideForm); + // The invoker's own CTM is untouched once Do returns. + Assert.Equal(Matrix.Identity, ctmAfterForm); + } + + private sealed class CtmProbeVisitor( + Action onFirstOperatorInsideForm, Action onFirstOperatorAfterForm) : IContentVisitor + { + private bool _insideForm; + private bool _formEnded; + + public void OnOperator(string operatorName, IReadOnlyList operands, int offset) + { + if (_insideForm) + onFirstOperatorInsideForm(); + else if (_formEnded) + onFirstOperatorAfterForm(); + } + + public void OnInlineImage(PdfDictionary dictionary, ReadOnlyMemory data, int offset) { } + + public void OnFormBegin( + PdfDictionary formDictionary, Matrix formMatrix, PdfRectangle? boundingBox, int objectNumber, + int offset) => + _insideForm = true; + + public void OnFormEnd(int objectNumber) + { + _insideForm = false; + _formEnded = true; + } + } + + // ── HandleDo reports a resource that resolves but is not a usable XObject (#402 round 2) ───── + + [Fact] + public void Do_namingAnEntryThatIsNotAnIndirectReference_reportsResourceMissing() + { + var (reader, _, visitor) = Run(BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 /NotAReference >> >>")); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + Assert.Contains(visitor.Operators, o => o.Op == "w"); // interpretation continued past Do + } + + [Fact] + public void Do_namingAReferenceThatDoesNotResolveToAStream_reportsResourceMissing() + { + var (reader, _, visitor) = Run(BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form >>"))); // no stream body: not a stream object + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void Do_namingAStreamWithNoSubtype_reportsResourceMissing() + { + var (reader, _, visitor) = Run(BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject >>", []))); // /Subtype absent + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void Do_namingAStreamWithASubtypeThatIsNeitherFormNorImage_reportsResourceMissing() + { + var (reader, _, visitor) = Run(BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Foo >>", []))); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void Do_namingAnImageXObject_reportsNoResourceMissing_andNoRecursion() + { + var (reader, _, visitor) = Run(BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 " + + "/ColorSpace /DeviceGray >>", [0]))); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + Assert.Contains(visitor.Operators, o => o.Op == "Do"); + Assert.Empty(visitor.FormBegins); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + // ── Do brackets a form's content in an implicit q/Q (ISO 32000-2 §8.10.1) ─────────────────── + + [Fact] + public void Do_onAFormThatChangesTheCtm_doesNotLeakTheChangeIntoTheInvoker() + { + var doc = BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", + "3 0 0 3 0 0 cm"u8.ToArray())); + + var (reader, state, _) = RunAndCaptureFinalState(doc); + + // 'w', the trailing top-level operator, is state-neutral (line width is not tracked), so + // the snapshot at 'w' still reflects the CTM exactly as Do's own finally restored it. + Assert.Equal(Matrix.Identity, state!.Ctm); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + } + + [Fact] + public void Do_onAFormThatOpensItsOwnTextObject_doesNotLeakTextMatricesIntoTheInvoker() + { + // Before the fix, _textState was never saved/restored around a form invocation (the class + // doc reasoned Do could never appear inside a text object, so there was nothing to + // disturb; that reasoning missed that the FORM's own content can open an entirely + // independent text object regardless of the invoker's own state). A form whose own content + // opens, moves, and closes its own text object silently overwrote the invoker's own + // TextMatrix once Do returned, with no diagnostic at all (#402 round 2). + var doc = BuildPageDoc( + "/F1 Do\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", + "BT 999 888 Td ET"u8.ToArray())); + + var (_, state, _) = RunAndCaptureFinalState(doc); + + // Same reasoning as the CTM test above: 'w' does not disturb either text matrix. + Assert.Equal(Matrix.Identity, state!.TextMatrix); + Assert.Equal(Matrix.Identity, state.TextLineMatrix); + } + + [Fact] + public void Do_insideATextObject_reportsOperandStackMalformedOnce_andStillRecurses() + { + // §8.2 Figure 9 admits no XObjects-category (Table 50) operator inside a text object. This does + // not itself stop the recursion (the form is still resolvable and drawn); it is purely a + // producer-side diagnostic (#402 round 2). + var doc = BuildPageDoc( + "BT\n/F1 Do\nET\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", [])); + + var (reader, _, visitor) = Run(doc); + + var reports = reader.Diagnostics.Where(d => + d.Code == PdfReaderDiagnosticCode.OperandStackMalformed + && d.Message.Contains("text object", StringComparison.Ordinal)).ToList(); + Assert.Single(reports); + Assert.Single(visitor.FormBegins); + Assert.Contains(visitor.Operators, o => o.Op == "w"); // interpretation continued + } + + [Fact] + public void Do_insideATextObject_judgesTheFormsOwnDoAgainstTheFormsOwnTextObjectState() + { + // The form's content starts outside any text object whatever the invoker was doing, so a + // 'Do' in a form that opens no BT of its own is not a violation even when the invoking + // 'Do' sat inside one. Before the HandleDo bracket saved the flag, the form's 'Do' was + // reported against the form's object number as well (#402 round 2). + var doc = BuildPageDoc( + "BT\n/F1 Do\nET\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /F2 12 0 R >> >> >>", + "/F2 Do\n"u8.ToArray()), + new Obj(12, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", [])); + + var (reader, _, visitor) = Run(doc); + + var reports = reader.Diagnostics.Where(d => + d.Code == PdfReaderDiagnosticCode.OperandStackMalformed + && d.Message.Contains("text object", StringComparison.Ordinal)).ToList(); + var report = Assert.Single(reports); + Assert.NotEqual(11, report.ObjectNumber); + Assert.Equal(2, visitor.FormBegins.Count); + } + + [Fact] + public void Do_onAFormWithAnUnbalancedBT_doesNotLeaveTheInvokerInsideATextObject() + { + // The flag is restored when the form returns, so a form whose content opens BT and never + // closes it (a §9.4.1 violation of the form's own) cannot make the invoker's next 'Do' + // look like it sits inside a text object. + var doc = BuildPageDoc( + "/F1 Do\n/F2 Do\n1 w\n", "<< /XObject << /F1 11 0 R /F2 12 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", "BT\n"u8.ToArray()), + new Obj(12, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", [])); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => + d.Code == PdfReaderDiagnosticCode.OperandStackMalformed + && d.Message.Contains("text object", StringComparison.Ordinal)); + Assert.Equal(2, visitor.FormBegins.Count); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void Do_onAFormWithUnbalancedQ_doesNotPopThePagesOwnSave() + { + var doc = BuildPageDoc( + "q\n/F1 Do\nQ\n1 w\n", "<< /XObject << /F1 11 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", "Q Q Q"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + // The form's three stray 'Q's each report against the form's own object number, 11, and + // the page's own 'q'/'Q' pairing (opened before Do, closed after it) stays untouched: the + // page's own Q must not itself be reported as unbalanced. + var malformed = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed).ToList(); + Assert.NotEmpty(malformed); + Assert.All(malformed, d => Assert.Equal(11, d.ObjectNumber)); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void Do_onAQOnlyForm_drawn70Times_producesNoGraphicsStateDepthDiagnostic() + { + var pageContent = string.Concat(Enumerable.Repeat("/F1 Do\n", 70)) + "1 w\n"; + var doc = BuildPageDoc( + pageContent, "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", "q\n"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void Do_onAFormWithAStrayEmcAndUnbalancedBX_leavesThePageStateBalanced() + { + var doc = BuildPageDoc( + "BDC\n/F1 Do\nEMC\nZorkAfterDo\n", + "<< /XObject << /F1 12 0 R >> >>", + new Obj( + 11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", "EMC"u8.ToArray()), + new Obj( + 12, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] " + + "/Resources << /XObject << /F1 11 0 R >> >> >>", + "/Span << >> BDC\n/F1 Do\n"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + // The innermost form's stray EMC is reported against ITS OWN object number (11), and the + // page's own BDC (opened before Do, closed by the page's own EMC after it) still balances: + // no diagnostic against the page (null object number) for an unmatched EMC. + var emcReports = reader.Diagnostics + .Where(d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed + && d.Message.Contains("EMC", StringComparison.Ordinal)) + .ToList(); + Assert.Contains(emcReports, d => d.ObjectNumber == 11); + Assert.DoesNotContain(emcReports, d => d.ObjectNumber is null); + + // The middle form's unbalanced BX leaves the PAGE outside a compatibility section once Do + // returns (the floor resets _bxDepth back to 0, not to "still inside BX"), so the page's + // own unknown operator right after Do is reported rather than silently swallowed. + Assert.Contains( + reader.Diagnostics, + d => d.Code == PdfReaderDiagnosticCode.UnknownOperator + && d.Message.Contains("ZorkAfterDo", StringComparison.Ordinal)); + } + + // ── The 64 MiB content budget covers Form XObject invocations too (#402) ─────────────────── + + [Fact] + public void FormDrawnRepeatedly_countsTowardTheSameContentBudgetAsThePage() + { + // A form whose own decoded content is ~20 MiB, drawn 4 times: well past the combined + // 64 MiB page-and-forms budget on the 4th invocation, without any single form or the + // page's own /Contents alone being anywhere near the cap. + var unit = "0 0 1 1 re\n"u8.ToArray(); + var repeatsPerForm = (20 * 1024 * 1024) / unit.Length; + var formBody = new byte[unit.Length * repeatsPerForm]; + for (var i = 0; i < repeatsPerForm; i++) + unit.CopyTo(formBody, i * unit.Length); + + var pageContent = string.Concat(Enumerable.Repeat("/F1 Do\n", 4)) + "1 w\n"; + var doc = BuildPageDoc( + pageContent, "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", formBody)); + + var (reader, _, visitor) = Run(doc); + + var tooLarge = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.ContentStreamTooLarge).ToList(); + Assert.Single(tooLarge); + + // Expected operator count computed from the byte budget, not read off the output: the + // whole 64 MiB budget divided by one form's own byte length gives how many WHOLE forms + // fit, each contributing repeatsPerForm 're' operators (the page's own /Contents is a few + // bytes and negligible against a 64 MiB budget). + var wholeFormsThatFit = (int)(ContentInterpreterBudget.MaxContentBytes / formBody.Length); + var reOps = visitor.Operators.Where(o => o.Op == "re").ToList(); + Assert.True(reOps.Count >= wholeFormsThatFit * repeatsPerForm); + Assert.True(reOps.Count < 4 * repeatsPerForm); + Assert.Contains(visitor.Operators, o => o.Op == "w"); // the page's own content after the last Do still ran + } + + // Mirrors ContentInterpreter's own private MaxContentBytes so the test above can compute an + // expected operator count from the budget rather than reading it off the interpreter's output. + private static class ContentInterpreterBudget + { + internal const long MaxContentBytes = 64L * 1024 * 1024; + } + + // ── The per-Run content budget is charged per element as chunks arrive (#402 round 2) ──────── + + [Fact] + public void ContentsArray_referencingTheSameOversizedStreamTenTimes_decodesOnlyFourElements() + { + // Before the fix, BuildPageContentBuffer decoded and held EVERY /Contents array element in + // memory before Concatenate ever got a chance to apply the budget: peak heap scaled with + // element count x MaxDecodedStreamBytes rather than with the budget itself. Ten references + // to the same ~20 MiB-decoding stream reproduces that (one 20 MiB Flate stream referenced + // 128 times from a 103 KB file measured 4662 MiB peak managed heap while interpreting + // exactly the same operators a four-element array would); charging the budget as each + // chunk arrives means only the first four references are ever decoded at all. Three fit + // comfortably inside the 64 MiB budget, and the fourth is what pushes the running total + // over it, so ContentStreamsDecoded pins that count directly rather than asserting on + // wall-clock time or process memory. + var unit = "0 0 1 1 re\n"u8.ToArray(); + var repeatsPerChunk = (20 * 1024 * 1024) / unit.Length; + var chunkBody = new byte[unit.Length * repeatsPerChunk]; + for (var i = 0; i < repeatsPerChunk; i++) + unit.CopyTo(chunkBody, i * unit.Length); + var encoded = Flate(chunkBody); + + var tenRefsDoc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents [4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R " + + "4 0 R 4 0 R] >>"), + new Obj(4, "<< /Filter /FlateDecode >>", encoded)); + + var (reader, interpreter, visitor) = Run(tenRefsDoc); + + Assert.Equal(4, interpreter.ContentStreamsDecoded); + var tooLarge = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.ContentStreamTooLarge).ToList(); + Assert.Single(tooLarge); + + var fourRefsDoc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents [4 0 R 4 0 R 4 0 R 4 0 R] >>"), + new Obj(4, "<< /Filter /FlateDecode >>", encoded)); + var (_, _, fourVisitor) = Run(fourRefsDoc); + + Assert.Equal(fourVisitor.Operators.Count, visitor.Operators.Count); + } + + [Fact] + public void FormDecodingToEightMiB_drawn64Times_decodesExactlyEightTimes() + { + // A2 (#402 round 2): HandleDo checked _contentBytesRemaining AFTER decoding a form's + // content, so a decode whose result was about to be discarded (budget already spent) still + // paid the full allocation and filter cost every single invocation. 64 MiB / 8 MiB = 8: the + // budget check hoisted above the decode means invocations past the 8th skip decoding + // entirely rather than decoding (and counting) a 9th time only to discard it, the way an + // inexactly-sized form still would (a sliver of budget left over after 8 whole decodes + // still reads as "budget available" to the entry check, so a 9th decode still runs and only + // gets truncated afterward). + var unit = "0 0 1 1 re\n"u8.ToArray(); + const int formSize = 8 * 1024 * 1024; + var repeats = formSize / unit.Length; + var formBody = new byte[formSize]; + for (var i = 0; i < repeats; i++) + unit.CopyTo(formBody, i * unit.Length); + Array.Fill(formBody, (byte)'\n', repeats * unit.Length, formSize - repeats * unit.Length); + + var pageContent = string.Concat(Enumerable.Repeat("/F1 Do\n", 64)); + var doc = BuildPageDoc( + pageContent, "<< /XObject << /F1 11 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", formBody)); + + var (_, interpreter, _) = Run(doc); + + // 9, not 8: ContentStreamsDecoded also counts the page's own /Contents decode + // (BuildPageContentBuffer.AddElement increments it too, per A1), which runs once before + // any 'Do' and spends a small slice of the same budget on the page's own "'/F1 Do' x 64" + // bytes; the 8 form decodes below are what this test pins. + Assert.Equal(9, interpreter.ContentStreamsDecoded); + } + + // ── Inline images ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public void UnfilteredGray8Bit_computedLength_skipsEmbeddedEiBytes() + { + byte[] pixelData = [0x45, 0x49, 0x10, 0x20]; // literally contains 'E','I' + var content = BuildInlineImageContent( + "/W 2 /H 2 /BPC 8 /CS /G", pixelData, trailingOperator: "Q"); + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(pixelData)); + Assert.Equal(2, ((PdfInteger)img.Dict.Get(new PdfName("Width"))!).Value); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void L_isHonoured() + { + byte[] data = "ABCDEFGH"u8.ToArray(); + var content = + "BI /W 2 /H 2 /BPC 8 /CS /RGB /F /AHx /L " + data.Length + " ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + } + + [Fact] + public void L_pastTheEnd_reportsMalformed_andRecoversViaTheEiScan() + { + byte[] data = "ABCDEFGH"u8.ToArray(); + var content = + "BI /F /AHx /L 999999 ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void NegativeL_reportsMalformed_andRecoversViaTheEiScan() + { + byte[] data = "ABCDEFGH"u8.ToArray(); + var content = "BI /F /AHx /L -1 ID " + Encoding.ASCII.GetString(data) + " EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Theory] + [InlineData("4294967298")] // outside int's range + [InlineData("2.9")] // a PdfReal, not a PdfInteger (Table 87 types /W as integer) + public void InvalidW_takesTheEiScanPath_withTheMissingOrInvalidReport(string invalidWidth) + { + byte[] data = "ABCD"u8.ToArray(); + var content = $"BI /W {invalidWidth} /H 2 /BPC 8 /CS /G ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains( + reader.Diagnostics, + d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed + && d.Message.Contains("missing, or carries an invalid", StringComparison.Ordinal)); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + } + + // ── NOTE 2's "skip any further white-space" for ASCIIHexDecode/ASCII85Decode (#402 round 2) ── + + [Fact] + public void A85WithTwoSpacesAfterId_andExplicitL_skipsBothSeparatorBytes() + { + // §8.9.7 NOTE 2: once the single required separator is consumed, a filter of + // ASCIIHexDecode or ASCII85Decode skips any FURTHER white-space too, before /L's own bytes + // are counted. Before this fix, at most one whitespace byte was ever consumed regardless of + // filter, so the second space here was misread as the payload's own first byte and /L=4 + // came out 3 bytes short of the image data. + byte[] data = "ABCD"u8.ToArray(); + var content = $"BI /F /A85 /L {data.Length} ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void A85WithTwoSpacesAfterId_andNoL_stillSkipsBothSeparatorBytes() + { + // Same as above but falling all the way to the EI scan (tier c) instead of an explicit /L: + // before this fix, the extra space landed inside the scanned data too, so the recovered + // bytes were " ABCD" (5, with the stray leading space) rather than "ABCD" (4). + byte[] data = "ABCD"u8.ToArray(); + var content = "BI /F /A85 ID " + Encoding.ASCII.GetString(data) + " EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void A85WithCrLfThenTwoMoreSpaces_skipsAllOfThem() + { + // The CR-LF-as-one-EOL-marker separator (§7.2.3) is consumed first, then the extra + // whitespace loop below still applies on top of it for an ASCII85Decode filter. + byte[] data = "ABCD"u8.ToArray(); + var content = $"BI /F /A85 /L {data.Length} ID\r\n " + + Encoding.ASCII.GetString(data) + " EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void AHxWithTwoSpacesAfterId_andExplicitL_skipsBothSeparatorBytes() + { + byte[] data = "ABCD"u8.ToArray(); + var content = $"BI /F /AHx /L {data.Length} ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void RawImageWithTwoSpacesAfterId_keepsTheSecondSpaceAsData() + { + // Control: a filter NOT in {ASCIIHexDecode, ASCII85Decode} (here, no filter at all) still + // consumes exactly one separator byte, per the normative sentence's own "Unless ...". The + // second space is the payload's own first sample byte, not something to skip. + var content = "BI /W 5 /H 1 /BPC 8 /CS /G ID ABCD EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(" ABCD"u8.ToArray())); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void FilterArrayWithA85Second_doesNotSkipExtraWhitespace_flateOwnsThePosition() + { + // §8.9.7 NOTE 2's skip recipe applies only to the filter that reads the raw bytes right + // after ID: the FIRST element of a /Filter array (decode order, §7.4.1's own EXAMPLE 2), + // never a later one. '/F [/Fl /A85]' names FlateDecode first, so FlateDecode owns the raw + // bytes; A85's presence at position 1 must not trigger the extra-whitespace skip meant for + // whichever filter sits at position 0 (#402 round 3: an earlier version skipped whenever + // AHx/A85 appeared ANYWHERE in the array, eating the payload's own leading 0x20 byte here + // and sliding the whole 8-byte window one byte late, delivering "62 63 64 65 66 67 68 20" + // instead of the correct "20 62 63 64 65 66 67 68"). + byte[] payload = [0x20, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68]; + var content = "BI /F [/Fl /A85] /L 8 ID "u8.ToArray() + .Concat(payload) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(payload)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void FilterArrayWithANonNameElementZero_doesNotSkipExtraWhitespace() + { + // '/F [null /A85]': CollectFilterNames drops the non-name element rather than keeping its + // place in the array, so a naive filterNames[0] read would see /A85 there and wrongly skip + // extra whitespace as though A85 itself sat at position 0. The raw array's own position 0 + // is null, neither AHx nor A85, so no extra whitespace should be skipped here at all + // (#402 round 4). The payload's own leading byte is a space (0x20): if the bug were still + // present, the extra-whitespace-skip loop would eat it as though it followed a + // single-white-space-then-skip-more rule that does not apply to this filter shape. + byte[] payload = [0x20, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68]; + var content = "BI /F [null /A85] /L 8 ID "u8.ToArray() + .Concat(payload) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(payload)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void FilteredAHx_usesTheEiScan() + { + byte[] data = "0123456789ABCDEF"u8.ToArray(); + var content = "BI /F /AHx ID " + Encoding.ASCII.GetString(data) + " EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + } + + [Fact] + public void DctInlineImage_withAFalseEiInsideItsData_isSkippedByTheScan() + { + // A whitespace-delimited "EI" followed by an unterminated literal string, which never + // closes anywhere in the rest of the content stream. The resync probe's lex attempt from + // that point throws, so ScanForEi rejects it and keeps looking for the terminating 'EI'. + var falseCandidate = " EI (unterminated "u8.ToArray(); + byte[] jpegNoise = [0x01, 0x02, 0xFF, 0xD8, 0xFF]; + var data = jpegNoise.Concat(falseCandidate).Concat(new byte[] { 3, 4, 5 }).ToArray(); + + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void DctInlineImage_withAFalseEiFollowedByBinaryNoise_isSkippedByTheScan() + { + // The harder false candidate: " EI " followed by bytes outside ISO 32000-2 §7.2.3 Tables 1 + // and 2's whitespace and delimiter sets, which the lexer accepts as one Keyword token + // without throwing. Only the probe's operator check (the keyword is not in Annex A Table + // A.1) rejects this one; the terminating EI follows and is accepted. + var falseCandidate = " EI "u8.ToArray(); + byte[] noiseAfter = [0x8F, 0x12, 0xC4, 0x7A, 0x20, 0xFE, 0x01]; + byte[] jpegNoise = [0xFF, 0xD8, 0xFF, 0xE0, 0x00]; + var data = jpegNoise.Concat(falseCandidate).Concat(noiseAfter).ToArray(); + + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Equal("Q", Assert.Single(visitor.Operators).Op); + } + + // ── The bounded resync probe (#402 round 3 redesign): a per-Run byte budget shared by every + // 'EI' candidate, not a per-candidate window, bounds ScanForEi's total cost regardless of + // content size or candidate count ──────────────────────────────────────────────────────────── + + [Fact] + public void ManyFalseEiCandidates_atOneMebibyte_exhaustsTheProbeBudgetExactlyOnce() + { + // Reproduces the pre-round-3 cost blowup this shape drove into the two-window probe: N + // repeats of " EI (" (a false candidate immediately followed by the start of a literal + // string that never closes anywhere in the rest of the buffer) after a DCT-filtered + // image's own data, then the terminating ' EI'. Measured against the round-2 probe: 1 MiB + // decoded content, 16,608 ms; 4 MiB, 71,320 ms; the 64 MiB content cap extrapolates to + // about 19 minutes, with no diagnostic to explain the cost. Every candidate here is close + // enough to the buffer's own true end that the round-3 probe's window is UNCLIPPED for the + // first several: each of those pays a full scan to the true end trying to close the never- + // closing string (an outright Reject, not a budget artefact) before the 16 MiB run budget + // itself runs out partway through one candidate's own window, which is what turns that one + // candidate's own outcome into Exhausted (accepted unverified) instead. One MiB of content + // is what makes that transition land inside this one test run: about 16 unclipped full- + // buffer rejects (~1 MiB each) exhaust the 16 MiB budget, so the total cost this shape can + // ever impose is bounded by the budget, not by how many further false candidates follow. + const int decodedContentBytes = 1024 * 1024; + var falseCandidate = " EI ("u8.ToArray(); + var prefix = "BI /F /DCT ID "u8.ToArray().Concat([0xFF, 0xD8, 0xFF]).ToArray(); + var suffix = " EI\nQ\n"u8.ToArray(); + var noiseLength = decodedContentBytes - prefix.Length - suffix.Length; + var repeats = noiseLength / falseCandidate.Length; + var noise = new byte[falseCandidate.Length * repeats]; + for (var i = 0; i < repeats; i++) + falseCandidate.CopyTo(noise, i * falseCandidate.Length); + + var content = prefix.Concat(noise).Concat(suffix).ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, interpreter, visitor) = Run(doc); + + const long maxProbeBytesPerRun = 16L * 1024 * 1024; // mirrors ContentInterpreter's own private const + Assert.Equal(maxProbeBytesPerRun, interpreter.ProbeBytesConsumed); + + // Exactly the budget report and the outer lexer's own report on the unterminated literal + // string the accepted-unverified candidate leaves behind (#402 round 3): the accepted + // candidate's own trailing '(' never closes anywhere in the rest of the buffer (every later + // '(' in the noise just nests deeper), so InterpretStream's own main loop hits + // InvalidDataException once it reaches true end of buffer still inside that string. + Assert.Equal(2, reader.Diagnostics.Count); + Assert.Contains( + reader.Diagnostics, + d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed + && d.Message.Contains("accepted without verification", StringComparison.Ordinal)); + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Single(visitor.InlineImages); // the (false) accepted-unverified candidate delimits it + } + + [Fact] + public void DctImage_withNoFalseCandidates_atFourMebibytes_probesAtMostTheTrailingOperator() + { + // The other half of the round-3 bound: a clean image (no false 'EI' candidates at all) + // must cost the probe almost nothing, however large the image itself is, since ScanForEi + // finds the terminating 'EI' on its very first byte-level match and LooksLikeResyncPoint probes + // forward from there exactly once. + const int imageBytes = 4 * 1024 * 1024; + var data = new byte[imageBytes]; + // 'E'/'I' bytes deliberately excluded so no coincidental candidate exists in the noise. + for (var i = 0; i < data.Length; i++) + data[i] = (byte)(0x01 + (i % 0x40)); + + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, interpreter, visitor) = Run(doc); + + // <= 2: the bytes of 'Q' the single ProbeOnce call from just past the terminating 'EI' consumes. + Assert.True( + interpreter.ProbeBytesConsumed <= 2, + $"expected the probe to spend at most 2 bytes on a candidate-free image; spent " + + $"{interpreter.ProbeBytesConsumed}."); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Theory] + [InlineData(4093)] // token exactly 4096 bytes: already correct under the old two-window probe + [InlineData(4094)] + [InlineData(5000)] + [InlineData(40_000)] + public void RealEi_followedByALiteralOfAnyLength_isAlwaysAccepted_noExtendedWindowLeft(int litLen) + { + // Round-3 HIGH 2: the round-2 probe's extended (4096-byte) window rejected the terminating + // 'EI' when the legitimate token right after it was longer than that window, losing the + // image and the rest of the stream (litLen 4094 and up reported InlineImageMalformed and + // delivered nothing). The round-3 probe has no per-candidate window at all, only the shared + // per-Run budget (far larger than any of these), so every length here is accepted the same + // way. + var longLiteral = new string('Y', litLen); + byte[] data = [0xFF, 0xD8, 0xFF]; + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(Encoding.ASCII.GetBytes($" EI ({longLiteral}) Tj\nQ\n")) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + var tj = Assert.Single(visitor.Operators, o => o.Op == "Tj"); + var str = (PdfLiteralString)tj.Operands[0]; + Assert.Equal(longLiteral, Encoding.ASCII.GetString(str.Bytes.Span)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + public static IEnumerable NeutralStraddlingShapes() + { + yield return ["[1 2 3"]; // unterminated array + yield return ["< d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.InRange(img.Data.Length, 315, 335); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void StreamEndingRightAfterEi_stillDelimitsTheImage(bool trailingWhitespace) + { + // A candidate probed at the buffer's own true end (not merely a budget-clipped window) is + // a legitimate resync point on its own: AtEnd on an unclipped window accepts. + byte[] data = [0xFF, 0xD8, 0xFF]; + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(trailingWhitespace ? " EI \n"u8.ToArray() : " EI"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + } + + [Fact] + public void ScanForEi_dataEndingInCrLfBeforeEi_stripsBothBytes() + { + // §8.9.7 excludes "the white-space delimiting those operators" from the image data; §7.2.3 + // makes a CARRIAGE RETURN immediately followed by a LINE FEED ONE EOL marker. Before this + // fix, ScanForEi stripped only the LF, leaving a trailing 0D byte in the delivered data + // (#402 round 3; the ID side already consumed a CR LF pair as one separator). + byte[] data = [0xFF, 0xD8, 0xFF]; + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat("\r\nEI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void FalseEiCandidate_followedByAnUnknownButPrintableOperator_isAccepted() + { + // 'PS' is an unrecognised-but-printable operator name (§7.8.2 tolerates it outside + // BX/EX); the probe accepts it as a plausible operator rather than rejecting the whole + // candidate on account of it, so the false EI right after the image data still resolves + // correctly, and 'PS' itself reaches the ordinary unknown-operator path once the + // interpreter's own main loop gets there. + byte[] data = [0xFF, 0xD8, 0xFF]; + var content = "q\nBI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(" EI PS Q 1 w\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains( + reader.Diagnostics, + d => d.Code == PdfReaderDiagnosticCode.UnknownOperator + && d.Message.Contains("'PS'", StringComparison.Ordinal)); + Assert.Equal(["q", "Q", "w"], visitor.Operators.Select(o => o.Op)); + } + + [Fact] + public void FalseEiCandidate_followedByABxExSection_isAccepted() + { + byte[] data = [0xFF, 0xD8, 0xFF]; + var content = "q\nBI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(" EI BX { } EX Q 1 w\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void TwoConsecutiveInlineImages_theSecondWithAShortDictionary_bothDelimitCorrectly() + { + byte[] data1 = [0x10, 0x20, 0x30, 0x40]; // no 'E'/'I' bytes: unambiguous EI scan + byte[] data2 = [0x99]; // /H is absent, so this one falls to the EI scan too + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data1).Concat(" EI\n"u8.ToArray()) + .Concat("BI /IM true /W 8 ID "u8.ToArray()) + .Concat(data2).Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.Equal(2, visitor.InlineImages.Count); + Assert.True(visitor.InlineImages[0].Data.AsSpan().SequenceEqual(data1)); + Assert.True(visitor.InlineImages[1].Data.AsSpan().SequenceEqual(data2)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void FalseEiCandidate_followedByALongLiteralStringStraddlingTheProbeWindow_isAccepted() + { + // The round-2 probe's own first, 128-byte window ran off mid-string here (the literal is + // 200 bytes, longer than that window) and needed a second, larger window to accept it. The + // round-3 probe has no fixed window at all, only the shared per-Run budget (#402 round 3), + // so a 200-byte literal that closes properly is neutral and keeps the probe lexing until it + // reaches 'Tj', a known operator: accepted the same way, just without a retry. + var longLiteral = new string('X', 200); + byte[] data = [0xFF, 0xD8, 0xFF]; + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(Encoding.ASCII.GetBytes($" EI ({longLiteral}) Tj\nQ\n")) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + var tj = Assert.Single(visitor.Operators, o => o.Op == "Tj"); + var str = (PdfLiteralString)tj.Operands[0]; + Assert.Equal(longLiteral, Encoding.ASCII.GetString(str.Bytes.Span)); + } + + // ── A token that never closes at all is rejected regardless of how far it runs, since the + // shared per-Run budget only turns "ran out of budget" into an accept, never "ran off a fixed + // per-candidate window" (#402 round 3; see ProbeOnce's windowClipped distinction) ──────────── + + [Theory] + [InlineData(100)] + [InlineData(110)] + [InlineData(200)] + [InlineData(5000)] + public void FalseEiCandidate_insideAnUnterminatedLiteralString_isRejected_regardlessOfLength(int n) + { + // A false ' EI' candidate immediately followed by an unterminated literal string that + // never closes ANYWHERE in the rest of the buffer, then N filler bytes, then the + // terminating ' EI\nQ\n'. The remaining buffer here (a few KB at most) is always far + // smaller than the 16 MiB per-Run probe budget, so the probe's window is never clipped by + // that budget for any of these: the string never closes at all, so it is rejected outright + // at every N tested, the same way a round-1 regression (accepting a token that merely ran + // past a FIXED window) never had a chance to reappear once that fixed window itself was + // removed. + var falseCandidate = " EI (never closed"u8.ToArray(); + var filler = new byte[n]; + Array.Fill(filler, (byte)'z'); + byte[] jpegNoise = [0x01, 0x02, 0xFF, 0xD8, 0xFF]; + var data = jpegNoise.Concat(falseCandidate).Concat(filler).ToArray(); + + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void FalseEiCandidate_insideAnUnterminatedHexString_isRejected() + { + // The hex-string counterpart of the literal-string case above: an unclosed '<' run that + // never closes anywhere in the rest of the buffer. + var falseCandidate = " EI <414243"u8.ToArray(); // '<' opens a hex string that never closes + var filler = new byte[200]; + Array.Fill(filler, (byte)'0'); + byte[] jpegNoise = [0x01, 0x02, 0xFF, 0xD8, 0xFF]; + var data = jpegNoise.Concat(falseCandidate).Concat(filler).ToArray(); + + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void RealEi_followedByATerminated4000ByteLiteral_isAccepted() + { + // The true terminating 'EI' must still be accepted even when what follows it is a long but + // well-formed token: rejecting the resync point that terminates the image just because + // legitimate content after it happens to be long would be exactly as wrong as accepting one + // that never closes at all. See RealEi_followedByALiteralOfAnyLength_isAlwaysAccepted_noExtendedWindowLeft + // for the round-3 boundary cases the old two-window probe got wrong (litLen >= 4094). + var longLiteral = new string('Y', 4000); + byte[] data = [0xFF, 0xD8, 0xFF]; + var content = "BI /F /DCT ID "u8.ToArray() + .Concat(data) + .Concat(Encoding.ASCII.GetBytes($" EI ({longLiteral}) Tj\nQ\n")) + .ToArray(); + var doc = BuildPageDocRaw(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + var tj = Assert.Single(visitor.Operators, o => o.Op == "Tj"); + var str = (PdfLiteralString)tj.Operands[0]; + Assert.Equal(longLiteral, Encoding.ASCII.GetString(str.Bytes.Span)); + } + + // ── The terminating 'EI' followed by a token that runs off the buffer's own TRUE end (not + // merely a probe window) is weaker evidence than a malformed byte found strictly inside an + // unclipped window, so it is accepted as a fallback rather than rejected outright the way that + // stronger case still is (#402 round 4; see ProbeOutcome.WeakReject and ScanForEi) ───────────── + + [Theory] + [InlineData("(abc")] // unterminated literal string, runs to the buffer's true end + [InlineData(" d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(jpeg)); + Assert.Empty(visitor.Operators); + } + + // ── A failed tier-a/tier-b end falls back to the scan instead of losing the stream (#402) ── + + [Fact] + public void L_oneShort_reportsMalformed_andRecoversViaTheEiScan() + { + byte[] data = "ABCD"u8.ToArray(); + var content = $"BI /F /AHx /L {data.Length - 1} ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void IdFollowedByCrLf_unfilteredImage_treatsBothBytesAsOneSeparator() + { + var content = "BI /W 2 /H 2 /BPC 8 /CS /G ID\r\nABCD EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual("ABCD"u8.ToArray())); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void IdFollowedByCrLf_withExplicitL_treatsBothBytesAsOneSeparator() + { + var content = "BI /F /AHx /L 4 ID\r\nABCD EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual("ABCD"u8.ToArray())); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void CrAsSingleSeparator_withDataStartingWithLf_landsOnEiWithNoRetry() + { + // §8.9.7's own single-white-space-byte reading, taken as this reader's primary one (#402 + // round 4): the separator is a lone CR, and the image's own declared length (/L 4) starts + // right after it, at the LF the producer meant as the image's own first data byte. Folding + // CR LF together here (the old primary reading) would have eaten that LF as part of the + // separator instead, dropping it from the delivered data. + byte[] data = [0x0A, 0x41, 0x42, 0x43]; + var raw = "BI /W 4 /H 1 /BPC 8 /CS /G /L 4 ID\r"u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(raw); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void CrLfSeparator_whoseOneByteReadingMissesEi_recoversViaTheRetry() + { + // The twin of the repro just above: here the producer wrote a two-byte CR LF separator + // (§7.2.3's own fold), so the one-byte-first primary reading (CR alone) lands one + // byte short of 'EI' and must retry with both bytes consumed, the mirror image of the retry + // 'CrAsSingleSeparator_withDataStartingWithLf_landsOnEiWithNoRetry' does not need. + byte[] data = "ABCD"u8.ToArray(); + var raw = "BI /W 4 /H 1 /BPC 8 /CS /G /L 4 ID\r\n"u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(raw); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void CrLfSeparator_whosePayloadEndsInWhiteSpace_isReadTheStrictWayWithNoRetry() + { + // Pins the §8.9.7 one-byte reading for this ambiguous file, not a defect. The producer wrote a + // two-byte CR LF separator, and the payload's own true last byte is itself white space (LF), so + // the strict one-byte reading, which leaves the LF from the CR LF pair at the front of the data + // instead of consuming it as part of the separator, still lands on 'EI': SkipToEi skips leading + // white space before 'EI', and that displaced trailing byte gets skipped the same way. The CR-LF + // retry above this test never runs, so the visitor receives the data shifted one byte with no + // diagnostic; §8.9.7 gives the reader no way to tell this apart from a producer that meant a + // lone CR instead. + byte[] delivered = [0x0A, 0x41, 0x42, 0x43]; + byte[] payload = [0x41, 0x42, 0x43, 0x0A]; + var raw = "BI /W 4 /H 1 /BPC 8 /CS /G /L 4 ID\r\n"u8.ToArray() + .Concat(payload) + .Concat("EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(raw); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(delivered)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void CrLfSeparator_whoseLengthIsWrongUnderBothReadings_recoversThroughTheScanWithoutALeadingLf() + { + // /L 2 is wrong for a 4-byte payload, so neither the one-byte reading nor the CR LF retry + // lands on 'EI' and the image falls through to the EI scan. That scan has no length to + // verify a reading against, so it takes tier c's own reading, the CR LF fold: the recovered + // data must be the payload alone, not the payload with the separator's LF prepended. + byte[] data = "ABCD"u8.ToArray(); + var raw = "BI /W 4 /H 1 /BPC 8 /CS /G /L 2 ID\r\n"u8.ToArray() + .Concat(data) + .Concat(" EI\nQ\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(raw); + + var (reader, _, visitor) = Run(doc); + + var diag = Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.Contains("did not land on an 'EI' operator", diag.Message); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void CrAsSingleSeparator_withDataStartingWithLf_recoversWithoutAWarning() + { + // §8.9.7 mandates exactly ONE white-space byte after 'ID'; here that byte is a lone CR, and + // the image's own first byte happens to be LF. §7.2.3's CR-LF-as-one-EOL-marker fold has no + // role here (#402 round 4): this reader's own primary reading takes only the CR as the + // separator, so the 5-byte window (LF,'A','B','C','D') this repro needs is exactly what the + // primary reading produces on the first try now, landing directly on 'EI' with no retry at + // all. Before round 4's inversion, the primary reading folded CR LF together, shifting the + // window one byte late and needing a retry to recover; before round 2, that retry existed + // but InlineImageMalformed was reported unconditionally ahead of it regardless of outcome, + // so a conforming file recovered cleanly still carried a warning about it. + var raw = "BI /W 5 /H 1 /BPC 8 /CS /G ID\r"u8.ToArray() + .Concat("\nABCDEI\nQ\n1 w\n"u8.ToArray()) + .ToArray(); + var doc = BuildPageDocRaw(raw); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual("\nABCD"u8.ToArray())); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void W_zero_reportsMalformed_andRecoversViaTheEiScan() + { + var content = "BI /W 0 /H 2 /BPC 8 /CS /G ID ABCD EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual("ABCD"u8.ToArray())); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void L_asARealNumber_reportsMalformed_andRecoversViaTheEiScan() + { + // Table 91 types /L as an integer; a PdfReal there (dropped silently before this fix) is + // reported the same way an invalid /W, /H, or /BPC already is (#402 round 2). The message + // says "present but" rather than "missing, or" (#402 round 3: this branch is reachable only + // once /L IS present, so a "missing" message here was never accurate). + var content = "BI /F /AHx /L 4.0 ID ABCD EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains( + reader.Diagnostics, + d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed + && d.Message.Contains("present but its value is not an integer", StringComparison.Ordinal)); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual("ABCD"u8.ToArray())); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Theory] + [InlineData(3)] + [InlineData(7)] + [InlineData(9)] + public void InvalidBpc_notInTheFixedSet_takesTheEiScanPath(int bpc) + { + // Table 87 restricts /BPC's own value to 1, 2, 4, 8, or 16, not merely "a positive + // integer"; a value like 3 was accepted before this fix (#402 round 2). + byte[] data = "ABCD"u8.ToArray(); + var content = $"BI /W 2 /H 2 /BPC {bpc} /CS /G ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains( + reader.Diagnostics, + d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed + && d.Message.Contains("missing, or carries an invalid", StringComparison.Ordinal)); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + } + + [Fact] + public void WidthHeightBpc_allIntMaxValue_recoversViaTheEiScan_withoutOverflowing() + { + // int.MaxValue is rejected by the /BPC set-membership check before it ever reaches + // TryComputeUnfilteredLength's own (long) width * bpc * components multiplication, closing + // the theoretical overflow that check guards against (#402 round 2): this must recover + // through the EI scan rather than throwing or hanging. + byte[] data = "ABCD"u8.ToArray(); + var content = $"BI /W {int.MaxValue} /H {int.MaxValue} /BPC {int.MaxValue} /CS /G ID " + + Encoding.ASCII.GetString(data) + " EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(data)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + // ── Table 92 abbreviations inside a /CS array (#402) ──────────────────────────────────────── + + [Fact] + public void CsArray_tableNinetyTwoAbbreviationsExpand_andIndexedComponentCountDrivesTierB() + { + // §8.9.7's one composite inline colour space: [/I baseSpace hival lookup]. /I and /RGB + // (the array's first two elements) are Table 92 abbreviations; 1 and the hex string are + // left alone. An array whose first element is /Indexed counts one component regardless of + // the base space (§8.6.6.3: an Indexed sample is always a single index value), so tier b + // computes the data length without resolving /DeviceRGB's own component count at all. A + // literal " EI x" is embedded inside the tier-b-computed 5-byte data window: if this + // regressed to the EI scan (tier c) instead, that decoy would be mistaken for the + // terminator, and the image would come out 1 byte long instead of 5. + var content = "BI /W 5 /H 1 /BPC 8 /CS [/I /RGB 1 <000000FFFFFF>] ID EI x EI\nQ\n1 w\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + var img = Assert.Single(visitor.InlineImages); + var csArray = (PdfArray)img.Dict.Get(PdfName.ColorSpace)!; + Assert.Equal(4, csArray.Count); + Assert.Equal("Indexed", ((PdfName)csArray[0]).Value); + Assert.Equal("DeviceRGB", ((PdfName)csArray[1]).Value); + Assert.Equal(1, ((PdfInteger)csArray[2]).Value); + Assert.True(img.Data.AsSpan().SequenceEqual(" EI x"u8.ToArray())); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void AbbreviationExpansion_isPinned() + { + var content = "BI /W 2 /H 2 /BPC 8 /CS /G /F [/AHx /Fl] ID XYZDATA1234 EI\nQ\n"; + + var (_, _, visitor) = Run(BuildPageDoc(content)); + + var img = Assert.Single(visitor.InlineImages); + Assert.Equal(2, ((PdfInteger)img.Dict.Get(new PdfName("Width"))!).Value); + Assert.Equal( + "DeviceGray", ((PdfName)img.Dict.Get(PdfName.ColorSpace)!).Value); + var filters = (PdfArray)img.Dict.Get(PdfName.Filter)!; + Assert.Equal("ASCIIHexDecode", ((PdfName)filters[0]).Value); + Assert.Equal("FlateDecode", ((PdfName)filters[1]).Value); + } + + [Fact] + public void NamedColorSpace_resolvesThroughResourcesColorSpace() + { + var content = "BI /W 1 /H 1 /BPC 8 /CS /MyCS ID X EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc( + content, "<< /ColorSpace << /MyCS [/Indexed /DeviceRGB 0 (\\000\\000\\000)] >> >>")); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + Assert.Single(visitor.InlineImages); + } + + [Fact] + public void NamedColorSpace_whenAbsentFromResources_reportsResourceMissing() + { + var content = "BI /W 1 /H 1 /BPC 8 /CS /MyCS ID X EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ResourceMissing); + Assert.Single(visitor.InlineImages); // still delimited via the EI scan fallback + } + + [Fact] + public void JpxDecodeInlineImage_reportsMalformed_andTheStreamContinuesAfterIt() + { + var content = "BI /W 1 /H 1 /BPC 8 /F /JPXDecode ID XYZ EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.Empty(visitor.InlineImages); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + // §8.9.7 excludes JBIG2Decode, JPXDecode, and Crypt from inline-image use; the disallowed + // filter still delimits the image (§8.9.7 gives this reader everything it needs to find the + // 'EI'), so interpretation continues past it, but the callback is skipped for that one image + // (#402 round 4: pinning all three filter names, not JPXDecode alone, and pinning that the + // callback itself, not merely the diagnostic, is skipped). + + [Fact] + public void Jbig2DecodeInlineImage_reportsMalformed_andTheStreamContinuesAfterIt() + { + var content = "BI /F /JBIG2Decode /L 3 ID abc EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.Empty(visitor.InlineImages); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + [Fact] + public void CryptInlineImage_reportsMalformed_andTheStreamContinuesAfterIt() + { + var content = "BI /F /Crypt /L 3 ID abc EI\nQ\n"; + + var (reader, _, visitor) = Run(BuildPageDoc(content)); + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.Empty(visitor.InlineImages); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + // ── 307's own (code, object, page) dedupe is per CONTENT STREAM, not per page: a page invoking + // two forms, each carrying its own malformed inline image, reports two 307s, one against each + // form's own object number, since the sink never sees the same key twice (#402 round 4) ────── + + [Fact] + public void InlineImageMalformed_inTwoDifferentForms_reportsTwice() + { + var formBody = "BI /F /JPXDecode /L 3 ID abc EI\nQ\n"u8.ToArray(); + var content = "/A Do\n/B Do\n"; + var doc = BuildPageDoc( + content, "<< /XObject << /A 11 0 R /B 12 0 R >> >>", + new Obj(11, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", formBody), + new Obj(12, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>", formBody)); + + var (reader, _, visitor) = Run(doc); + + var reports = reader.Diagnostics + .Where(d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed) + .ToList(); + Assert.Equal(2, reports.Count); + Assert.Empty(visitor.InlineImages); + Assert.Equal(2, visitor.Operators.Count(o => o.Op == "Q")); + } + + [Fact] + public void InlineImageMalformed_onThePagesOwnContentAlone_reportsOnce() + { + var content = "BI /F /JPXDecode /L 3 ID abc EI\nQ\n"; + + var (reader, _, _) = Run(BuildPageDoc(content)); + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + } + + [Fact] + public void MissingId_reportsMalformed() + { + var (reader, _, visitor) = Run(BuildPageDoc("BI /W 1 /H 1")); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.Empty(visitor.InlineImages); + } + + private static string BuildInlineImageContent( + string keys, byte[] data, string trailingOperator) + { + var sb = new StringBuilder(); + sb.Append("BI ").Append(keys).Append(" ID "); + var prefix = sb.ToString(); + var suffix = " EI\n" + trailingOperator + "\n"; + return prefix + Encoding.Latin1.GetString(data) + suffix; + } + + // ── /Contents array concatenation ──────────────────────────────────────────────────────────── + + [Fact] + public void TokenSplitAcrossStreams_isNotGlued() + { + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents [4 0 R 5 0 R] >>"), + new Obj(4, "<< >>", "q\nBT"u8.ToArray()), // ends mid-token, no trailing whitespace + new Obj(5, "<< >>", "ET\nQ"u8.ToArray())); // begins with no leading whitespace + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.UnknownOperator); + Assert.Equal(["q", "BT", "ET", "Q"], visitor.Operators.Select(o => o.Op)); + } + + [Fact] + public void NonStreamContentsElement_isSkippedWithLexError() + { + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents [5 0 R 6 0 R] >>"), + new Obj(5, "42"), // not a stream at all + new Obj(6, "<< >>", "1 w"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Contains(visitor.Operators, o => o.Op == "w"); // object 6's content still ran + } + + [Fact] + public void ContentsStreamWithAnImageFilter_reportsLexError() + { + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>"), + new Obj(4, "<< /Filter /DCTDecode >>", [0xFF, 0xD8, 0xFF, 0xD9])); + + var (reader, _, visitor) = Run(doc); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Empty(visitor.Operators); + } + + [Fact] + public void LexerFailureInsideOneStream_endsTheWholeConcatenatedBuffer_laterElementsIncluded() + { + // The 300 doc's "resumes with the next stream in the array" clause holds only for an + // element that fails to RESOLVE or DECODE (NonStreamContentsElement_isSkippedWithLexError + // above): every element that DOES decode is concatenated into one buffer before + // interpretation of any of it begins (Table 31: "the division between streams may occur + // only at the boundaries between lexical tokens"), so a lexer or parser failure inside that + // buffer ends interpretation for the rest of the page's content, later array elements + // included, not merely the one stream the failure fell in (#402 round 3). Object 4 decodes + // to a token '1 0 ]' the object parser cannot make sense of (a lone ']' with no opening + // '['); object 5's own 'J'/'j' never run. + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents [4 0 R 5 0 R] >>"), + new Obj(4, "<< >>", "1 w\n1 0 ]\n"u8.ToArray()), + new Obj(5, "<< >>", "2 J\n3 j\n"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Equal(["w"], visitor.Operators.Select(o => o.Op)); + } + + // ── 64 MiB content cap ─────────────────────────────────────────────────────────────────────── + + [Fact] + public void ContentExceeding64MiB_reportsTooLarge_andKeepsTheOperatorsBeforeTheCap() + { + var unit = "0 0 1 1 re\n"u8.ToArray(); + var repeats = (67 * 1024 * 1024 / unit.Length) + 200_000; + var raw = new byte[unit.Length * repeats]; + for (var i = 0; i < repeats; i++) + unit.CopyTo(raw, i * unit.Length); + + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>"), + new Obj(4, "<< /Filter /FlateDecode >>", Flate(raw))); + + var (reader, _, visitor) = Run(doc); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamTooLarge); + Assert.NotEmpty(visitor.Operators); + Assert.True(visitor.Operators.Count < repeats); + Assert.All(visitor.Operators, o => + { + Assert.Equal("re", o.Op); + Assert.Equal(4, o.Operands.Count); + }); + } + + [Fact] + public void ContentExceeding64MiB_acrossTwoStreams_appendsTheSecondStreamAfterTheFirst_notOverIt() + { + // A regression pin for a wrong Array.Copy overload (#402): copying the truncated tail + // chunk to index 0 of the capped buffer, rather than to `written`, silently overwrote the + // FIRST stream's own already-copied bytes once a second stream needed truncating. The + // single-stream cap test above cannot catch this: it never has anything already written + // when the truncation branch runs (`written == 0` there), so the wrong overload and the + // right one produce identical output in that one-stream case. + var unitA = "0 0 1 1 re\n"u8.ToArray(); + var repeatsA = 40 * 1024 * 1024 / unitA.Length; + var rawA = new byte[unitA.Length * repeatsA]; + for (var i = 0; i < repeatsA; i++) + unitA.CopyTo(rawA, i * unitA.Length); + + var unitB = "1 w\n"u8.ToArray(); + var repeatsB = 30 * 1024 * 1024 / unitB.Length; + var rawB = new byte[unitB.Length * repeatsB]; + for (var i = 0; i < repeatsB; i++) + unitB.CopyTo(rawB, i * unitB.Length); + + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents [4 0 R 5 0 R] >>"), + new Obj(4, "<< /Filter /FlateDecode >>", Flate(rawA)), + new Obj(5, "<< /Filter /FlateDecode >>", Flate(rawB))); + + var (reader, _, visitor) = Run(doc); + + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamTooLarge); + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.OperandStackMalformed); + + var reOps = visitor.Operators.TakeWhile(o => o.Op == "re").ToList(); + Assert.Equal(repeatsA, reOps.Count); + Assert.All(reOps, o => Assert.Equal(4, o.Operands.Count)); + + var rest = visitor.Operators.Skip(reOps.Count).ToList(); + Assert.NotEmpty(rest); + Assert.True(rest.Count < repeatsB); + Assert.All(rest, o => + { + Assert.Equal("w", o.Op); + Assert.Single(o.Operands); + }); + } + + // ── ContentStreamTooLarge and FormXObjectBudgetExceeded use ReportRetained (#402) ─────────── + + [Fact] + public void ContentStreamTooLarge_isRetainedEvenOnceMaxDiagnosticsIsAlreadySpent() + { + // Two pages: page 0 spends the whole cap (MaxDiagnostics = 1) on an ordinary UnknownOperator + // report; page 1's /Contents then exceeds the 64 MiB budget. Without ReportRetained, that + // second report would be silently dropped in favour of the DiagnosticsSuppressed sentinel, + // since the sink's ordinary cap (shared across the whole reader, not per page) is already + // full by the time page 1 is interpreted (#398 set this rule for PageTreeWalker; this is + // the content-interpreter counterpart). + var unit = "0 0 1 1 re\n"u8.ToArray(); + var repeats = (67 * 1024 * 1024 / unit.Length) + 200_000; + var raw = new byte[unit.Length * repeats]; + for (var i = 0; i < repeats; i++) + unit.CopyTo(raw, i * unit.Length); + + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>"), + new Obj(4, "<< >>", "Zork\n"u8.ToArray()), + new Obj(6, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 7 0 R >>"), + new Obj(7, "<< /Filter /FlateDecode >>", Flate(raw))); + + var reader = PdfReader.Open(doc, new PdfReaderOptions { MaxDiagnostics = 1 }); + var interpreter = new ContentInterpreter(reader); + + interpreter.Run(reader.GetPage(0), new RecordingVisitor()); + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.UnknownOperator); + + interpreter.Run(reader.GetPage(1), new RecordingVisitor()); + Assert.Contains(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamTooLarge); + } + + // ── An unrecognised token is excerpted, not quoted whole (#402 round 6) ───────────────────── + // + // PdfLexer.ReadKeyword bounds neither a keyword's own length nor, per PdfName, a name's, so an + // unrecognised operator or a missing named resource used to decode and interpolate the whole + // token into its own diagnostic Message. A Diagnostic is retained for the reader's own + // lifetime, so a multi-megabyte token produced a comparably sized permanent allocation; these + // pin the fixed-size excerpt DiagnosticExcerpt.Quote reports instead. 4 MiB is well under both + // the 64 MiB content budget and PdfReaderOptions.MaxDecodedStreamBytes's own 512 MiB default, so + // neither needs raising for these fixtures. + + [Fact] + public void UnknownOperator_ofAttackerControlledLength_reportsOnlyAFixedExcerpt() + { + var keyword = new string('A', 4_194_304); + var content = Encoding.ASCII.GetBytes(keyword + "\n1 w\n"); + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>"), + new Obj(4, "<< /Filter /FlateDecode >>", Flate(content))); + + var (reader, _, visitor) = Run(doc); + + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.UnknownOperator).ToList(); + var report = Assert.Single(reports); + Assert.True( + report.Message.Length < 200, + $"expected a message under 200 chars, got {report.Message.Length}."); + var expectedExcerpt = $"'{new string('A', 32)}... (4194304 bytes)'"; + Assert.StartsWith(expectedExcerpt, report.Message, StringComparison.Ordinal); + + var w = Assert.Single(visitor.Operators); + Assert.Equal("w", w.Op); + } + + [Fact] + public void ResourceMissing_forADoNameOfAttackerControlledLength_reportsOnlyAFixedExcerpt() + { + var xobjectName = new string('B', 4_194_304); + var content = Encoding.ASCII.GetBytes($"/{xobjectName} Do\n1 w\n"); + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>"), + new Obj(4, "<< /Filter /FlateDecode >>", Flate(content))); + + var (reader, _, visitor) = Run(doc); + + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.ResourceMissing).ToList(); + var report = Assert.Single(reports); + var expectedExcerpt = $"/{new string('B', 32)}... (4194304 bytes)'"; + Assert.Contains(expectedExcerpt, report.Message, StringComparison.Ordinal); + + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Theory] + [InlineData(32, false)] + [InlineData(33, true)] + public void UnknownOperator_atTheExcerptBoundary_truncatesOnlyPastDiagnosticExcerptMaxChars( + int keywordLength, bool expectTruncation) + { + var keyword = new string('A', keywordLength); + var doc = BuildPageDocRaw(Encoding.ASCII.GetBytes(keyword + "\n")); + + var (reader, _, _) = Run(doc); + + var reports = reader.Diagnostics.Where(d => d.Code == PdfReaderDiagnosticCode.UnknownOperator).ToList(); + var report = Assert.Single(reports); + var expectedQuoted = expectTruncation + ? $"'{keyword[..32]}... ({keywordLength} bytes)'" + : $"'{keyword}'"; + Assert.Contains(expectedQuoted, report.Message, StringComparison.Ordinal); + } + + // ── Run's own outer catch no longer forwards ex.Message (#402 round 7) ────────────────────── + // + // PdfObjectParser's own "N G obj" header check and its numeric-range checks interpolate the + // whole malformed token into their own exception's Message, with no bound of its own (out of + // this PR's scope: PdfDocumentReader.Open's own callers read that Message directly too). + // BuildPageContentBuffer's ResolveStream/ResolveValue calls have no local catch for either, so + // an InvalidDataException from either used to reach Run's own outer catch and get interpolated + // into a diagnostic Message retained for the reader's own lifetime, in full. + + [Fact] + public void UnparsableContentsObjectHeader_reportsAFixedMessage_notTheWholeToken() + { + // Object 4's own "N G obj" header names no "obj" keyword at all, just 4,194,304 'A' bytes: + // PdfObjectParser.ParseIndirectObject's own ExpectToken(Keyword, "'obj' keyword") accepts + // that as A Keyword token, then IsKeyword(objKw.Raw, "obj") rejects it and throws with the + // whole token quoted. ResolveStream (PdfDocumentReader.cs) calls ParseIndirectObject with + // no local catch, so this reaches BuildPageContentBuffer's own AddElement (which only + // catches around GetDecodedStreamData, not around ResolveStream itself) and then Run's own + // outer catch, before InterpretStream is ever entered. + var badHeader = "4 0 " + new string('A', 4_194_304) + "\n"; + var doc = BuildPdfWithRawObjectBytes( + 1, + (1, Encoding.ASCII.GetBytes("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")), + (2, Encoding.ASCII.GetBytes("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")), + (3, Encoding.ASCII.GetBytes( + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>\nendobj\n")), + (4, Encoding.ASCII.GetBytes(badHeader))); + + var (reader, _, visitor) = Run(doc); + + var report = Assert.Single( + reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.True( + report.Message.Length < 200, $"expected under 200 chars, got {report.Message.Length}."); + Assert.Equal( + "The page's content could not be fully resolved: an object it references could not " + + "be parsed.", + report.Message); + Assert.Equal(0, report.PageIndex); + Assert.Empty(visitor.Operators); + } + + [Fact] + public void TwoPagesSharingAMalformedContentsDictionary_eachReportOneBoundedMessage() + { + // Same bug, reached through PdfObjectParser.ParseReal's own "Real number out of range" + // exception instead: a 2,000,000-digit real literal in the STREAM'S OWN dictionary parses + // to +Infinity under double.TryParse and is rejected there, while ParseDictionary is still + // parsing the dictionary itself, well before "stream" is ever looked for. Two pages share + // object 4, so each page's own Run independently re-parses it and independently throws: + // Run's own catch reports no object number, so the sink's (code, object, page) dedupe key + // does not collapse the two pages' reports into one the way an object-scoped report would. + var digits = new string('1', 2_000_000); + var doc = BuildPdfWithRawObjectBytes( + 1, + (1, Encoding.ASCII.GetBytes("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")), + (2, Encoding.ASCII.GetBytes( + "2 0 obj\n<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>\nendobj\n")), + (3, Encoding.ASCII.GetBytes( + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>\nendobj\n")), + (5, Encoding.ASCII.GetBytes( + "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>\nendobj\n")), + (4, Encoding.ASCII.GetBytes($"4 0 obj\n<< /Bogus {digits}.0 >>\nendobj\n"))); + + var reader = PdfReader.Open(doc, new PdfReaderOptions()); + var interpreter = new ContentInterpreter(reader); + interpreter.Run(reader.GetPage(0), new RecordingVisitor()); + interpreter.Run(reader.GetPage(1), new RecordingVisitor()); + + var reports = reader.Diagnostics + .Where(d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError).ToList(); + Assert.Equal(2, reports.Count); + foreach (var report in reports) + { + Assert.True( + report.Message.Length < 200, $"expected under 200 chars, got {report.Message.Length}."); + Assert.Equal( + "The page's content could not be fully resolved: an object it references could not " + + "be parsed.", + report.Message); + } + Assert.Equal([0, 1], reports.Select(r => r.PageIndex).OrderBy(p => p)); + var totalMessageLength = reader.Diagnostics.Sum(d => d.Message.Length); + Assert.True( + totalMessageLength < 1000, + $"expected under 1000 total chars across every diagnostic, got {totalMessageLength}."); + } + + // ── Filters.cs's UnknownFilter diagnostic is excerpted too (#402 round 8) ─────────────────── + + [Fact] + public void PageContentsWithAnOversizedUnknownFilterName_reportsOnlyBoundedMessages() + { + // Object 4's /Filter is a single 1,000,000-byte name PdfFilters.ApplyFilter does not + // recognise. This used to interpolate the name whole into a retained UnknownFilter (110) + // diagnostic before the resulting InvalidDataException reached AddElement's catch (see + // the two tests above), which reports a second, fixed-text ContentStreamLexError (300); the + // sweep that found this HIGH also confirmed AddElement's catch was already bounded. + var hugeFilter = new string('A', 1_000_000); + var doc = BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << >> /Contents 4 0 R >>"), + new Obj(4, $"<< /Filter /{hugeFilter} >>", "x"u8.ToArray())); + + var (reader, _, visitor) = Run(doc); + + foreach (var d in reader.Diagnostics) + { + Assert.True( + d.Message.Length < 200, $"expected under 200 chars, got {d.Message.Length} ({d.Code})."); + } + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.UnknownFilter); + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Empty(visitor.Operators); // Run returned normally, with no content to interpret. + } + + // ── Inline image dictionary values bypass the composite cap (#402 round 7) ───────────────────── + // + // The key/value loop in HandleInlineImage used to hand every value straight to + // parser.ParseObject() with no CompositeOperandWithinCap pre-scan, so an array or dictionary + // value here bypassed MaxCompositeOperandElements entirely, and the number of key/value pairs + // the loop admitted was uncapped too. + + [Fact] + public void InlineImageDictionaryArrayValue_overTheCompositeCap_dropsTheImage_boundingAllocation() + { + // /D [1 1 1 ...] used to be fully materialised as a PdfArray, one boxed PdfInteger per + // element, before any cap was consulted: measured (pre-fix, 10,000,000 elements) 892.4 MiB + // allocated for one dropped image and no diagnostic. 20,000 elements is already well over + // MaxCompositeOperandElements (8192), so this is over the cap on a ~40 KB content stream, + // not a stress case. + var content = "BI /D [" + string.Concat(Enumerable.Repeat("1 ", 20_000)) + "] ID ABC EI\n1 w\n"; + var doc = BuildPageDoc(content); + + var before = GC.GetAllocatedBytesForCurrentThread(); + var (reader, _, visitor) = Run(doc); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + var report = Assert.Single( + reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Equal( + "An inline image dictionary value exceeds 8192 tokens; the image was dropped.", + report.Message); + Assert.DoesNotContain( + reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Empty(visitor.InlineImages); + // HandleInlineImage's own "return false" ends the stream the same way every other + // malformed-dictionary path does (see HandleInlineImage's own class doc): '1 w', after the + // image's own 'EI', is never reached. + Assert.Empty(visitor.Operators); + // An allocation bound, not a wall-clock one (#400), but tight enough to + // discriminate the fix from the defect: on the PRE-fix path this /D array still gets fully + // materialised (about 82 bytes per element, 1.65 MB measured for 20,000 elements) before + // the cap is ever consulted, and 16 MiB left that far under the old bound, which is why + // round 8 tightened it. Measured on the fixed code: 96,952 bytes for this ~40 KB content + // stream (the cap is decided by the lexer alone, so the array is never materialised); 1 + // MiB leaves ample margin over the fixed figure while still failing on the unfixed one. + Assert.True( + allocated < 1L * 1024 * 1024, + $"expected under 1 MiB allocated; measured {allocated / 1024.0:F2} KiB."); + } + + [Fact] + public void InlineImageDictionaryFilterArrayValue_overTheCompositeCap_dropsTheImage() + { + // The /F-array branch (the abbreviation-expanding one) copies every element into a fresh + // List and PdfArray on top of parser.ParseObject()'s own allocation, so it is + // checked separately from the general /D case above: both call sites need the same + // pre-scan, not just one of them. + var content = "BI /F [" + string.Concat(Enumerable.Repeat("/AHx ", 20_000)) + "] ID ABC EI\n1 w\n"; + var doc = BuildPageDoc(content); + + var (reader, _, visitor) = Run(doc); + + var report = Assert.Single( + reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Equal( + "An inline image dictionary value exceeds 8192 tokens; the image was dropped.", + report.Message); + Assert.Empty(visitor.InlineImages); + Assert.Empty(visitor.Operators); + } + + [Fact] + public void InlineImageDictionaryValue_overTheCap_withAMalformedTokenInside_reportsTheLexErrorAlongsideTheCap() + { + // Mirrors OverCapArrayOperand_withAMalformedTokenInside_reportsTheLexErrorAlongsideTheCap + // above: the unterminated string right after the over-cap array is its own lex failure (no + // closing ')' anywhere in the rest of the buffer), not merely the count pass bailing out at + // the cap, so this branch (valueLexerFailed) has to report both a 309 and a 300, the same + // way the main operand loop's twin already does. + var content = "BI /D [" + string.Concat(Enumerable.Repeat("1 ", 20_000)) + " (abc ] ID ABC EI Q"; + var doc = BuildPageDoc(content); + + var (reader, _, visitor) = Run(doc); + + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentStreamLexError); + Assert.Empty(visitor.InlineImages); + Assert.Empty(visitor.Operators); + } + + [Theory] + [InlineData(8192, false)] + [InlineData(8193, true)] + public void InlineImageDictionaryArrayValue_atTheCompositeCapBoundary( + int elementCount, bool expectDropped) + { + var content = "BI /D [" + string.Concat(Enumerable.Repeat("1 ", elementCount)) + "] ID ABC EI\nQ\n"; + var doc = BuildPageDoc(content); + + var (reader, _, visitor) = Run(doc); + + if (expectDropped) + { + Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Empty(visitor.InlineImages); + Assert.DoesNotContain(visitor.Operators, o => o.Op == "Q"); + } + else + { + Assert.DoesNotContain( + reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + var img = Assert.Single(visitor.InlineImages); + var decodeArray = (PdfArray)img.Dict.Get(new PdfName("Decode"))!; + Assert.Equal(elementCount, decodeArray.Count); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + } + + [Theory] + [InlineData(65)] + [InlineData(100)] + public void InlineImageDictionary_withMorePairsThanTheCap_reportsOnce_andDropsTheImage(int pairCount) + { + // Table 91 lists eleven entries; a producer's own dictionary never comes close to 64, so + // this covers only a hostile BI...ID section: the check fires on the 65th key-value pair + // whether or not an ID ever follows it. 65 is the first count over the cap; 100 shows the + // report stays a single one however far past it. + var keys = string.Concat(Enumerable.Range(0, pairCount).Select(i => $"/K{i:D3} 1 ")); + var content = "BI " + keys + "ID ABC EI\nQ\n"; + var doc = BuildPageDoc(content); + + var (reader, _, visitor) = Run(doc); + + var report = Assert.Single(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + Assert.Equal( + "An inline image dictionary has more than 64 key-value pairs; the image was dropped.", + report.Message); + Assert.Empty(visitor.InlineImages); + Assert.DoesNotContain(visitor.Operators, o => o.Op == "Q"); + } + + [Theory] + [InlineData(60)] + [InlineData(64)] + public void InlineImageDictionary_withPairsUpToTheCap_deliversTheImage(int pairCount) + { + // 64 is the cap itself and must still deliver: the check rejects the 65th entry, not the + // 64th. + var keys = string.Concat(Enumerable.Range(0, pairCount).Select(i => $"/K{i:D3} 1 ")); + var content = "BI " + keys + "ID ABC EI\nQ\n"; + var doc = BuildPageDoc(content); + + var (reader, _, visitor) = Run(doc); + + Assert.DoesNotContain(reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.ContentLimitExceeded); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual("ABC"u8)); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + // ── An indirect reference as an inline image dictionary value (#402 round 9) ─────────────── + // + // §7.8.2: "Indirect objects and object references shall not be permitted at all" in a content + // stream. PdfObjectParser.ParseObject happily returns a PdfIndirectReference for "5 0 R"; the + // key/value loop used to store it in the delivered dictionary with no diagnostic at all. + + [Fact] + public void InlineImageDictionary_withAnIndirectReferenceValue_reportsInlineImageMalformed_andIgnoresTheEntry() + { + var content = "BI /F 5 0 R /W 1 /H 1 /BPC 8 /CS /G ID \x01 EI\n1 w\n"; + var doc = BuildPageDoc(content); + + var (reader, _, visitor) = Run(doc); + + var report = Assert.Single(reader.Diagnostics); + Assert.Equal(PdfReaderDiagnosticCode.InlineImageMalformed, report.Code); + Assert.Equal( + "An inline image dictionary value is an indirect reference, which §7.8.2 does not " + + "permit in a content stream; the entry was ignored.", + report.Message); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(new byte[] { 0x01 })); + Assert.Null(img.Dict.Get(PdfName.Filter)); + Assert.Null(img.Dict.Get(new PdfName("F"))); + Assert.Contains(visitor.Operators, o => o.Op == "w"); + } + + [Fact] + public void InlineImageDictionary_withAnIndirectReferenceWidth_reportsInlineImageMalformed_andRecoversViaTheEiScan() + { + // Ignoring the /W entry leaves TryComputeUnfilteredLength with no width to compute a data + // length from, so tier b (the unfiltered-length computation) declines and this falls + // through to tier c, the EI scan: with only one data byte before 'EI' the scan lands on it + // cleanly, so the image is still delivered, just without a /Width entry. The missing-/W + // report tier b would otherwise add is not separately observable: the sink's (code, object, + // page) dedupe key collapses it into the indirect-reference report already made for the + // same content stream, so only that first report survives. + var content = "BI /W 5 0 R /H 1 /BPC 8 /CS /G ID \x01 EI\nQ\n"; + var doc = BuildPageDoc(content); + + var (reader, _, visitor) = Run(doc); + + var report = Assert.Single( + reader.Diagnostics, d => d.Code == PdfReaderDiagnosticCode.InlineImageMalformed); + Assert.Equal( + "An inline image dictionary value is an indirect reference, which §7.8.2 does not " + + "permit in a content stream; the entry was ignored.", + report.Message); + var img = Assert.Single(visitor.InlineImages); + Assert.True(img.Data.AsSpan().SequenceEqual(new byte[] { 0x01 })); + Assert.Null(img.Dict.Get(new PdfName("Width"))); + Assert.NotNull(img.Dict.Get(new PdfName("Height"))); + Assert.Contains(visitor.Operators, o => o.Op == "Q"); + } + + // ── Run resets its own state on exit, not only entry (#402 round 7) ──────────────────────────── + + private sealed class FontOperandCapturingVisitor : IContentVisitor + { + public WeakReference? FontOperand { get; private set; } + + public void OnOperator(string operatorName, IReadOnlyList operands, int offset) + { + if (operatorName == "Tf" && FontOperand is null) + FontOperand = new WeakReference(operands[0]); + } + + public void OnInlineImage(PdfDictionary dictionary, ReadOnlyMemory data, int offset) { } + + public void OnFormBegin( + PdfDictionary formDictionary, Matrix formMatrix, PdfRectangle? boundingBox, int objectNumber, + int offset) + { } + + public void OnFormEnd(int objectNumber) { } + } + + [Fact] + public void Run_dropsAnUnconsumedFontOperandOnExit_soItBecomesCollectable() + { + // GraphicsState.Font (set by Tf) is the one PdfObject field this interpreter's own state + // keeps past the operator that set it. Without Run's own exit-time reset, an + // attacker-sized name operand pushed through Tf, with no later Tf or 'gs' to overwrite + // Font and no Q to pop it, stayed referenced by _gs for as long as this interpreter + // instance itself lived, not merely for the duration of Run (measured, pre-fix: a + // 4,194,304-byte name operand retained 33,562,352 bytes from a 16,779-byte file, 2000x; a + // second Run on the same interpreter did not release it either). A throwaway visitor is + // used here, not RecordingVisitor: RecordingVisitor's own Operators list keeps a strong + // reference to every operand it has ever seen, by design (#98), which would keep this + // WeakReference alive regardless of what Run itself does. + var content = "/" + new string('B', 4_194_304) + " 12 Tf\n1 w\n"; + var doc = BuildPageDoc(content); + var reader = PdfReader.Open(doc, new PdfReaderOptions()); + var page = reader.GetPage(0); + var interpreter = new ContentInterpreter(reader); + var visitor = new FontOperandCapturingVisitor(); + + interpreter.Run(page, visitor); + + var weakRef = visitor.FontOperand; + Assert.NotNull(weakRef); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False( + weakRef!.IsAlive, + "expected the Tf font operand to be collectable once Run has returned."); + GC.KeepAlive(interpreter); + } + + private static readonly FieldInfo OperandsField = + typeof(ContentInterpreter).GetField("_operands", BindingFlags.NonPublic | BindingFlags.Instance)!; + + // No operator ever reaches this operand (see the test below), so it never reaches + // IContentVisitor.OnOperator either, unlike the Tf-operand test above: an inline image placed + // after it gives OnInlineImage a callback that still fires while the operand is sitting, + // untouched, in the interpreter's operand list (HandleInlineImage never reads or clears + // that list), reached here through reflection since ContentInterpreter keeps the list private. + private sealed class PendingOperandCapturingVisitor(ContentInterpreter interpreter) : IContentVisitor + { + public WeakReference? PendingOperand { get; private set; } + + public void OnOperator(string operatorName, IReadOnlyList operands, int offset) { } + + public void OnInlineImage(PdfDictionary dictionary, ReadOnlyMemory data, int offset) + { + var operands = (List)OperandsField.GetValue(interpreter)!; + if (operands.Count > 0 && PendingOperand is null) + PendingOperand = new WeakReference(operands[0]); + } + + public void OnFormBegin( + PdfDictionary formDictionary, Matrix formMatrix, PdfRectangle? boundingBox, int objectNumber, + int offset) + { } + + public void OnFormEnd(int objectNumber) { } + } + + [Fact] + public void Run_dropsAnOperandNoOperatorConsumed_soItBecomesCollectable() + { + // Round 7's primary repro for the finally block's _operands.Clear(): an + // attacker-sized name pushed but never consumed by ANY operator (measured, pre-fix: + // 33,562,352 bytes retained from a 16,779-byte file). The 1x1 inline image after it is + // pure scaffolding to reach the operand through OnInlineImage (see + // PendingOperandCapturingVisitor above); it plays no other role in what this pins. + var content = "/" + new string('B', 16_777_216) + " BI /W 1 /H 1 /BPC 8 /CS /G ID \x01 EI\n"; + var doc = BuildPageDoc(content); + var reader = PdfReader.Open(doc, new PdfReaderOptions()); + var page = reader.GetPage(0); + var interpreter = new ContentInterpreter(reader); + var visitor = new PendingOperandCapturingVisitor(interpreter); + + interpreter.Run(page, visitor); + + var weakRef = visitor.PendingOperand; + Assert.NotNull(weakRef); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False( + weakRef!.IsAlive, + "expected the unconsumed operand to be collectable once Run has returned."); + GC.KeepAlive(interpreter); + } + + [Fact] + public void Run_dropsAnOperandOnlyAGraphicsStateCloneHolds_soItBecomesCollectable() + { + // Round 7's primary repro for the finally block's _gsStack.Clear(): PushGraphicsState + // pushes the CURRENT _gs onto _gsStack and replaces _gs with a clone (ContentInterpreter.cs, + // PushGraphicsState), so the first Tf's big name operand survives on the STACK, not in + // _gs, once a second Tf overwrites the clone's Font with something else. A throwaway + // visitor captures only the FIRST Tf's operand, the same way the test above does. + var content = "/" + new string('B', 4_194_304) + " 12 Tf\nq\n/F2 6 Tf\n1 w\n"; + var doc = BuildPageDoc(content); + var reader = PdfReader.Open(doc, new PdfReaderOptions()); + var page = reader.GetPage(0); + var interpreter = new ContentInterpreter(reader); + var visitor = new FontOperandCapturingVisitor(); + + interpreter.Run(page, visitor); + + var weakRef = visitor.FontOperand; + Assert.NotNull(weakRef); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False( + weakRef!.IsAlive, + "expected the first Tf's font operand, surviving only in the q clone on _gsStack, " + + "to be collectable once Run has returned."); + GC.KeepAlive(interpreter); + } + + // ── Fuzzing ────────────────────────────────────────────────────────────────────────────────── + + private static readonly byte[] FuzzContent = Encoding.ASCII.GetBytes( + "q\n2 0 0 2 10 20 cm\nBT\n/F1 12 Tf\n(Hi) Tj\n[(A) -10 (B)] TJ\nET\n" + + "/G1 gs\n/X1 Do\nBI /W 2 /H 2 /BPC 8 /CS /G ID \x01\x02\x03\x04 EI\nQ\n"); + + private static byte[] BuildFuzzDoc(byte[] content) => BuildPdf( + 1, + new Obj(1, "<< /Type /Catalog /Pages 2 0 R >>"), + new Obj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + new Obj(3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << /Font << /F1 6 0 R >> /XObject << /X1 7 0 R >> " + + "/ExtGState << /G1 8 0 R >> >> /Contents 4 0 R >>"), + new Obj(4, "<< >>", content), + new Obj(6, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"), + new Obj( + 7, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Matrix [1 0 0 1 0 0] >>", + "1 w\n"u8.ToArray()), + new Obj(8, "<< /Type /ExtGState /Font [6 0 R 10] >>")); + + private static readonly byte[] FuzzSeed = BuildFuzzDoc(FuzzContent); + + private readonly record struct MutationOp(int Kind, int Position, byte Value, int Length); + + private static readonly Gen MutationOpGen = + Gen.Select( + Gen.Int[0, 5], Gen.Int[0, int.MaxValue], Gen.Byte, Gen.Int[1, 32], + (kind, position, value, length) => new MutationOp(kind, position, value, length)); + + // Mutates the whole file's own bytes: exercises reconstruction, object resolution, and + // structural malformation, but a mutation landing in the xref, trailer, or object headers + // (most of the file, by byte count) usually keeps the interpreter from ever being reached at + // all: see ContentBodyFuzzInputGen below for the generator that exists to reach it reliably + // (#402 round 3). + private static readonly Gen FuzzInputGen = + MutationOpGen.Array[1, 8].Select(ops => Mutate(FuzzSeed, ops)); + + // Mutates only the page's own content-stream BODY, then rebuilds a structurally valid document + // around the result: every byte this generator can touch is one ContentInterpreter itself + // walks, so PdfReader.Open and GetPage keep succeeding on nearly every sample regardless of + // what the mutations did to operator syntax, giving Run deep, repeated coverage of the + // interpreter's own malformed-content recovery paths rather than mostly exercising xref repair + // (#402 round 3; ContentBodyFuzzGenerator_isRobust_andReachesRunOnTheMajorityOfSamples pins + // this generator's own reach rate). + private static readonly Gen ContentBodyFuzzInputGen = + MutationOpGen.Array[1, 8].Select(ops => BuildFuzzDoc(Mutate(FuzzContent, ops))); + + private static byte[] Mutate(byte[] seed, MutationOp[] ops) + { + var buffer = new List(seed); + foreach (var op in ops) + { + if (buffer.Count == 0) { buffer.Add(op.Value); continue; } + var position = op.Position % buffer.Count; + switch (op.Kind) + { + case 0: buffer[position] ^= (byte)(1 << (op.Value % 8)); break; + case 1: buffer[position] = op.Value; break; + case 2: buffer.RemoveAt(position); break; + case 3: buffer.Insert(position, op.Value); break; + case 4: + var length = Math.Min(op.Length, buffer.Count - position); + if (length > 0 && buffer.Count + length <= 1 << 20) + buffer.InsertRange(position, buffer.GetRange(position, length)); + break; + case 5: + var cut = position + 1; + if (cut < buffer.Count) + buffer.RemoveRange(cut, buffer.Count - cut); + break; + } + if (buffer.Count > 1 << 20) + buffer.RemoveRange(1 << 20, buffer.Count - (1 << 20)); + } + return buffer.Count == 0 ? [0] : [.. buffer]; + } + + [Fact] + public void Fuzz_run_neverThrowsOutsideTheDeclaredVocabulary_andAlwaysTerminates() + // A block-bodied lambda, not `bytes => AssertInterpreterIsRobust(bytes)`: CsCheck.Sample has + // both an Action overload (fails only on a thrown exception) and a Func one + // (fails whenever the delegate returns false). AssertInterpreterIsRobust now returns + // whether Run was reached (#402 round 3, for the majority-of-samples test below), and an + // expression-bodied lambda binds to the Func overload by exact delegate-type match, + // silently reinterpreting "this sample never reached Run" (true for ~95% of this generator's + // own samples) as a FAILING property case instead of the acceptable outcome it is. A + // block-bodied lambda has no expression value to match Func against, so it can only + // bind to Action, restoring the original semantics. + => FuzzInputGen.Sample(bytes => { AssertInterpreterIsRobust(bytes); }, iter: FuzzBudget.Iterations); + + [Fact] + public void ContentBodyFuzzGenerator_isRobust_andReachesRunOnTheMajorityOfSamples() + { + // One Sample() call, not two: it both runs the same robustness assertions + // Fuzz_run_neverThrowsOutsideTheDeclaredVocabulary_andAlwaysTerminates pins for the + // file-wide generator AND counts how many samples reach ContentInterpreter.Run at all. A + // separate Fact duplicating the robustness-only pass would double this generator's own + // CsCheck cost (every core, per the class doc's own #398/#399 contention lesson) for no + // extra coverage. + // + // The file-wide generator's own mutations mostly land in the xref, trailer, or object + // headers (most of the file, by byte count), so most samples never resolve a page at all: + // measured at 20,000 iterations, 990 of 19,628 samples (5.0%) reached Run: the interpreter + // itself is exercised on only a sliver of that generator's own budget. The content-body + // generator exists to fix that: every byte it can touch is inside the one content stream + // ContentInterpreter itself walks, so the surrounding file structure stays intact and Run + // is reached on nearly every sample instead (#402 round 3). Pinned by COUNT, not time + // (#400): a majority is the bar, generous against the near-100% this generator reaches in + // practice. threads: 1: this call does not need parallelism of its own to finish well + // within a test run, and a second CsCheck.Sample call in this same class independently + // trying to claim every core is needless oversubscription alongside + // Fuzz_run_neverThrowsOutsideTheDeclaredVocabulary_andAlwaysTerminates's own (the class + // doc's own #398/#399 note is why this file treats that as worth avoiding, not adding to). + var reached = 0L; + var total = 0L; + ContentBodyFuzzInputGen.Sample( + bytes => + { + total++; + if (AssertInterpreterIsRobust(bytes)) + reached++; + }, + iter: FuzzBudget.Iterations, threads: 1); + + Assert.True(total > 0); + Assert.True( + reached * 2 >= total, + $"the content-body fuzz generator reached Run on {reached}/{total} samples; expected a " + + "majority."); + } + + /// Runs the same robustness assertions + /// pins, and reports whether this sample reached ContentInterpreter.Run at all, so a + /// caller can separately measure how often each generator gets that far (#402 round 3). + private static bool AssertInterpreterIsRobust(byte[] bytes) + { + // Two separate try blocks, not one covering Open/GetPage/Run together: ContentInterpreter's + // own class doc promises InvalidDataException never escapes Run (UnsupportedPdfFeatureException + // is the one exception allowed to). One try block covering all three would make a Run-time + // InvalidDataException indistinguishable from an open-time one, silently accepting a + // regression in that promise as just another "acceptable outcome". + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + PdfDocumentReader? reader = null; + PdfReadPage? page = null; + try + { + reader = PdfReader.Open(bytes, new PdfReaderOptions + { + MaxDecodedStreamBytes = ReaderLimits.MinMaxDecodedBytes, + }); + if (reader.PageCount != 0) + page = reader.GetPage(0); + } + catch (Exception ex) when (ex is InvalidDataException or UnsupportedPdfFeatureException or PdfPasswordException) + { + // Acceptable outcome; see ParserFuzzTests' own class doc for the same policy this + // interpreter follows: a robustness oracle, not a conformance one. + } + + var reachedRun = page is not null; + if (page is not null) + { + try + { + var interpreter = new ContentInterpreter(reader!); + interpreter.Run(page, new RecordingVisitor()); + } + catch (UnsupportedPdfFeatureException) + { + // The one exception Run's own class doc allows to propagate. + } + } + + reader?.Dispose(); + Assert.True( + stopwatch.Elapsed <= TimeSpan.FromSeconds(4), + $"content interpretation took {stopwatch.Elapsed} on a {bytes.Length}-byte input."); + return reachedRun; + } + + private static class FuzzBudget + { + private const long DefaultIterations = 3_000; + + /// + /// Iterations per fuzz / case. Overridable via + /// VELLUMPDF_FUZZ_ITER so the nightly workflow can run a much larger budget without + /// the PR-gating default paying for it. A copy of ParserFuzzTests.FuzzBudget (#402 + /// round 4: restored the doc a prior copy of this type dropped), kept as two separate + /// private nested types rather than one shared internal one, since the two test classes' + /// own fuzz targets (the lexer/parser/reader pipeline there, this interpreter here) are + /// unrelated enough that sharing state between them is not worth the coupling. + /// + internal static long Iterations + { + get + { + var raw = Environment.GetEnvironmentVariable("VELLUMPDF_FUZZ_ITER"); + return long.TryParse(raw, out var parsed) && parsed > 0 ? parsed : DefaultIterations; + } + } + } +} diff --git a/tests/VellumPdf.Reader.Tests/DiagnosticRoutingTests.cs b/tests/VellumPdf.Reader.Tests/DiagnosticRoutingTests.cs index c761097..f834c2e 100644 --- a/tests/VellumPdf.Reader.Tests/DiagnosticRoutingTests.cs +++ b/tests/VellumPdf.Reader.Tests/DiagnosticRoutingTests.cs @@ -287,6 +287,46 @@ public void UnknownFilter_stillThrows_butReportsErrorFirst() Assert.Equal(PdfReaderDiagnosticSeverity.Error, d.Severity); } + [Fact] + public void UnknownFilter_withAnOversizedName_reportsOnlyAFixedExcerpt() + { + // A /Filter name has no length bound (Annex C.1), and UnknownFilter is retained for + // the reader's lifetime, so before this fix the whole name was interpolated via + // filter.Value: the same class of defect DiagnosticExcerpt exists to bound elsewhere in + // this reader (#402 round 8). + var hugeFilter = new string('A', 1 << 20); + var dict = new PdfDictionary().Set(PdfName.Filter, new PdfName(hugeFilter)); + var stream = MakeParsedStream(dict, "hello"u8.ToArray()); + var sink = new DiagnosticSink(cap: 10); + + Assert.Throws(() => PdfFilters.Decode(stream, ReaderLimits.Defaults, diagnostics: sink)); + + var d = Assert.Single(sink.Diagnostics, x => x.Code == PdfReaderDiagnosticCode.UnknownFilter); + Assert.Equal( + "Unknown PDF filter: /" + new string('A', 32) + "... (1048576 bytes).", + d.Message); + } + + [Theory] + [InlineData(32, false)] + [InlineData(33, true)] + public void UnknownFilter_atTheExcerptBoundary_quotesThirtyTwoWhole_andExcerptsThirtyThree( + int nameLength, bool expectExcerpt) + { + var name = new string('A', nameLength); + var dict = new PdfDictionary().Set(PdfName.Filter, new PdfName(name)); + var stream = MakeParsedStream(dict, "hello"u8.ToArray()); + var sink = new DiagnosticSink(cap: 10); + + Assert.Throws(() => PdfFilters.Decode(stream, ReaderLimits.Defaults, diagnostics: sink)); + + var d = Assert.Single(sink.Diagnostics, x => x.Code == PdfReaderDiagnosticCode.UnknownFilter); + var expected = expectExcerpt + ? "Unknown PDF filter: /" + new string('A', 32) + $"... ({nameLength} bytes)." + : $"Unknown PDF filter: /{name}."; + Assert.Equal(expected, d.Message); + } + [Fact] public void DecodedStreamLimitExceeded_stillThrows_butReportsErrorFirst() { diff --git a/tests/VellumPdf.Reader.Tests/PageTreeTests.cs b/tests/VellumPdf.Reader.Tests/PageTreeTests.cs index 560c2c7..6c57592 100644 --- a/tests/VellumPdf.Reader.Tests/PageTreeTests.cs +++ b/tests/VellumPdf.Reader.Tests/PageTreeTests.cs @@ -584,6 +584,32 @@ public void RootIsTheCatalogItself_withKidsBoltedOn_reportsPageTreeMissing() Assert.Equal(1, d.ObjectNumber); } + [Fact] + public void RootWithAnOversizedType_reportsOnlyAFixedExcerpt() + { + // A /Type value has no length bound of its own (Annex C.1), and PageTreeMissing is retained + // for the reader's own lifetime, so before this fix the root's own bogus /Type name was + // interpolated whole via PdfName.ToString(): the same class of defect DiagnosticExcerpt + // exists to bound, found sweeping this PR's other diagnostic sites for it (#402 round 7). + var hugeType = new string('A', 1_000_000); + var bytes = BuildPdf( + rootObjectNumber: 1, + (1, "<< /Type /Catalog /Pages 2 0 R >>"), + (2, $"<< /Type /{hugeType} /Kids [3 0 R] /Count 1 >>"), + (3, "<< /Type /Page /MediaBox [0 0 100 100] >>")); + + using var reader = Open(bytes); + + Assert.Equal(0, reader.PageCount); + var d = Assert.Single(reader.Diagnostics, x => x.Code == PdfReaderDiagnosticCode.PageTreeMissing); + // The fixed sentence this excerpt sits inside is itself close to 200 chars (the ISO + // citation), so the bound here is against the 1,000,000-char /Type value this message used + // to carry whole, not against PageTreeMissing's own fixed text. + Assert.True(d.Message.Length < 300, $"expected under 300 chars, got {d.Message.Length}."); + var expectedExcerpt = $"/Type /{new string('A', 32)}... (1000000 bytes)"; + Assert.Contains(expectedExcerpt, d.Message, StringComparison.Ordinal); + } + [Fact] public void RootWithEmptyKids_yieldsZeroPages_withNoDiagnosticAtAll() { @@ -642,6 +668,31 @@ public void KidClassifiedByType_wrongTypeIsSkipped_neitherNodeNorPage() Assert.Contains(malformed, d => d.ObjectNumber == 3); } + [Fact] + public void KidWithAnOversizedType_reportsOnlyAFixedExcerpt() + { + // Same defect as RootWithAnOversizedType_reportsOnlyAFixedExcerpt above, on the OTHER + // ClassifyByType Skip site (a non-root node reached through /Kids): the sink's own (code, + // object, page) dedupe key does not collapse this against a DIFFERENT object's own report, + // so several oversized-/Type kids each retain their own excerpt-bounded copy rather than + // sharing amplification the way a page-scoped report would (#402 round 7). + var hugeType = new string('B', 1_000_000); + var bytes = BuildPdf( + rootObjectNumber: 1, + (1, "<< /Type /Catalog /Pages 2 0 R >>"), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + (3, $"<< /Type /{hugeType} >>")); + + using var reader = Open(bytes); + + Assert.Equal(0, reader.PageCount); + var d = Assert.Single(reader.Diagnostics, x => x.Code == PdfReaderDiagnosticCode.PageTreeNodeMalformed); + Assert.Equal(3, d.ObjectNumber); + Assert.True(d.Message.Length < 200, $"expected under 200 chars, got {d.Message.Length}."); + var expectedExcerpt = $"Object declares /{new string('B', 32)}... (1000000 bytes)"; + Assert.Contains(expectedExcerpt, d.Message, StringComparison.Ordinal); + } + [Fact] public void TypePagesWithNoUsableKids_reportsNodeMalformed_contributesNoChildren() { diff --git a/tests/VellumPdf.Reader.Tests/PdfLexerContentModeTests.cs b/tests/VellumPdf.Reader.Tests/PdfLexerContentModeTests.cs new file mode 100644 index 0000000..081e461 --- /dev/null +++ b/tests/VellumPdf.Reader.Tests/PdfLexerContentModeTests.cs @@ -0,0 +1,240 @@ +// Copyright © Timothy van der Ham (@Tim81) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; + +namespace VellumPdf.Reader.Tests; + +/// +/// Pins 's content-stream mode (#98, part 3): the default constructor's +/// behaviour must stay byte-identical (it still throws on a lone {, }, or unmatched +/// >), and the new internal PdfLexer(ReadOnlyMemory<byte>, bool) constructor +/// must accept those same three bytes as one-byte tokens (ISO +/// 32000-2 §7.8.2) while lexing every other token kind identically to the default constructor. +/// +public sealed class PdfLexerContentModeTests +{ + // ── Default constructor: unchanged behaviour ──────────────────────────────────────────────── + + [Theory] + [InlineData("{")] + [InlineData("}")] + [InlineData(">")] + public void DefaultConstructor_stillThrowsOnPostScriptHeritageBytes(string input) + { + var lexer = new PdfLexer(Encoding.ASCII.GetBytes(input)); + + Assert.Throws(() => lexer.NextToken()); + } + + [Fact] + public void DefaultConstructor_stillThrowsOnUnmatchedGreaterThan_evenMidStream() + { + var lexer = new PdfLexer(Encoding.ASCII.GetBytes("1 0 obj > endobj")); + lexer.NextToken(); // 1 + lexer.NextToken(); // 0 + lexer.NextToken(); // obj + + Assert.Throws(() => lexer.NextToken()); + } + + // ── Content-stream mode: the three bytes become one-byte Keyword tokens ──────────────────────── + + [Theory] + [InlineData("{")] + [InlineData("}")] + [InlineData(">")] + public void ContentStreamMode_lexesPostScriptHeritageBytesAsOneByteKeywordTokens(string input) + { + var bytes = Encoding.ASCII.GetBytes(input); + var lexer = new PdfLexer(bytes, contentStreamMode: true); + + var token = lexer.NextToken(); + + Assert.Equal(TokenKind.Keyword, token.Kind); + Assert.Equal(1, token.Raw.Length); + Assert.Equal(input[0], (char)token.Raw.Span[0]); + Assert.Equal(1, lexer.Position); + } + + [Fact] + public void ContentStreamMode_lexesADictionaryEndImmediatelyAfterALoneGreaterThan() + { + // "> >>": a lone '>' keyword token, then a two-byte '>>' dictionary-end token right after it. + var lexer = new PdfLexer(Encoding.ASCII.GetBytes("> >>"), contentStreamMode: true); + + var first = lexer.NextToken(); + var second = lexer.NextToken(); + + Assert.Equal(TokenKind.Keyword, first.Kind); + Assert.Equal(TokenKind.DictEnd, second.Kind); + } + + [Fact] + public void ContentStreamMode_stillLexesADoubleGreaterThanAsDictEnd_notTwoLoneKeywords() + { + var lexer = new PdfLexer(Encoding.ASCII.GetBytes(">>"), contentStreamMode: true); + + var token = lexer.NextToken(); + + Assert.Equal(TokenKind.DictEnd, token.Kind); + Assert.Equal(2, lexer.Position); + } + + [Fact] + public void ContentStreamMode_lexesTwoLoneGreaterThansSplitByWhitespace_asTwoOneByteKeywords() + { + // ">>" is one DictEnd token (above); "> >", with whitespace between the two bytes, is two + // separate one-byte Keyword tokens instead. + var lexer = new PdfLexer(Encoding.ASCII.GetBytes("> >"), contentStreamMode: true); + + var first = lexer.NextToken(); + var second = lexer.NextToken(); + var third = lexer.NextToken(); + + Assert.Equal(TokenKind.Keyword, first.Kind); + Assert.Equal(1, first.Raw.Length); + Assert.Equal(TokenKind.Keyword, second.Kind); + Assert.Equal(1, second.Raw.Length); + Assert.Equal(TokenKind.EndOfInput, third.Kind); + } + + [Fact] + public void ContentStreamMode_seekingPastAOneByteKeyword_lexesTheFollowingTokenNormally() + { + var lexer = new PdfLexer(Encoding.ASCII.GetBytes("{Tj"), contentStreamMode: true); + + var brace = lexer.NextToken(); + Assert.Equal(TokenKind.Keyword, brace.Kind); + Assert.Equal(1, lexer.Position); + + lexer.Seek(1); + var next = lexer.NextToken(); + + Assert.Equal(TokenKind.Keyword, next.Kind); + Assert.Equal("Tj", Encoding.ASCII.GetString(next.Raw.Span)); + } + + // TokenKind (internal) named by string here too, for the same CS0051 reason as + // AllTokenKindFixtures below. + [Theory] + [InlineData("}5", nameof(TokenKind.Integer), "5")] + [InlineData("}/N", nameof(TokenKind.Name), "/N")] + [InlineData("}(s)", nameof(TokenKind.LiteralString), "(s)")] + public void ContentStreamMode_aOneByteKeyword_abuttingANonKeywordToken_lexesBothSeparately( + string input, string expectedSecondKindName, string expectedSecondRaw) + { + var expectedSecondKind = Enum.Parse(expectedSecondKindName); + var lexer = new PdfLexer(Encoding.ASCII.GetBytes(input), contentStreamMode: true); + + var first = lexer.NextToken(); + var second = lexer.NextToken(); + + Assert.Equal(TokenKind.Keyword, first.Kind); + Assert.Equal(1, first.Raw.Length); + Assert.Equal((byte)'}', first.Raw.Span[0]); + Assert.Equal(expectedSecondKind, second.Kind); + Assert.Equal(expectedSecondRaw, Encoding.ASCII.GetString(second.Raw.Span)); + } + + [Fact] + public void ContentStreamMode_insideACompatibilitySection_lexesAWholeBxToExSequenceWithoutThrowing() + { + // A PostScript-heritage compatibility fragment ISO 32000-2 §7.8.2 permits inside BX/EX. + var bytes = Encoding.ASCII.GetBytes("BX { pop } EX"); + var lexer = new PdfLexer(bytes, contentStreamMode: true); + + var kinds = new List(); + Token tok; + while ((tok = lexer.NextToken()).Kind != TokenKind.EndOfInput) + kinds.Add(tok.Kind); + + Assert.Equal( + [ + TokenKind.Keyword, // BX + TokenKind.Keyword, // { + TokenKind.Keyword, // pop + TokenKind.Keyword, // } + TokenKind.Keyword, // EX + ], + kinds); + } + + [Fact] + public void ContentStreamMode_stillThrowsOnALoneCloseParen() + { + // Content-stream mode relaxes exactly three bytes ('{', '}', a lone '>'), per this file's + // own class doc; a lone ')' is not one of them and must keep throwing (#402 round 3, C5: a + // boundary pin so a later change to what this mode relaxes cannot widen it here silently). + var lexer = new PdfLexer(")"u8.ToArray(), contentStreamMode: true); + + Assert.Throws(() => lexer.NextToken()); + } + + [Fact] + public void ContentStreamMode_stillThrowsOnAnUnterminatedLiteralString() + { + var lexer = new PdfLexer("(never closed"u8.ToArray(), contentStreamMode: true); + + Assert.Throws(() => lexer.NextToken()); + } + + // ── Every other token kind lexes identically in both modes ───────────────────────────────────── + + // MemberData parameters must be public-accessible types (CS0051), so TokenKind (internal, via + // this test assembly's InternalsVisibleTo friendship) is named by string here and parsed back + // inside the theory body instead of being the parameter type itself. + public static IEnumerable AllTokenKindFixtures() + { + yield return ["123", nameof(TokenKind.Integer)]; + yield return ["-.5", nameof(TokenKind.Real)]; + yield return ["6.", nameof(TokenKind.Real)]; + yield return ["/Name#20With#23Escape", nameof(TokenKind.Name)]; + yield return ["(literal (nested) string)", nameof(TokenKind.LiteralString)]; + yield return ["<48656C6C6F>", nameof(TokenKind.HexString)]; + yield return ["[", nameof(TokenKind.ArrayBegin)]; + yield return ["]", nameof(TokenKind.ArrayEnd)]; + yield return ["<<", nameof(TokenKind.DictBegin)]; + yield return ["true", nameof(TokenKind.Keyword)]; + yield return ["Tj", nameof(TokenKind.Keyword)]; + } + + [Theory] + [MemberData(nameof(AllTokenKindFixtures))] + public void EveryOtherTokenKind_lexesIdenticallyInBothModes(string input, string expectedKindName) + { + var expectedKind = Enum.Parse(expectedKindName); + var bytes = Encoding.ASCII.GetBytes(input); + var defaultLexer = new PdfLexer(bytes); + var contentLexer = new PdfLexer(bytes, contentStreamMode: true); + + var defaultToken = defaultLexer.NextToken(); + var contentToken = contentLexer.NextToken(); + + Assert.Equal(expectedKind, defaultToken.Kind); + Assert.Equal(defaultToken.Kind, contentToken.Kind); + Assert.True(defaultToken.Raw.Span.SequenceEqual(contentToken.Raw.Span)); + Assert.Equal(defaultLexer.Position, contentLexer.Position); + } + + [Fact] + public void EveryTokenKind_overOneFixtureCoveringAllOfThem_lexesIdenticallyInBothModes() + { + const string fixture = + "q 1 0 0 1 10 20 cm /F1 12 Tf (Hello) Tj <48656C6C6F> Tj [1 2 3] true false null Q"; + var bytes = Encoding.ASCII.GetBytes(fixture); + var defaultLexer = new PdfLexer(bytes); + var contentLexer = new PdfLexer(bytes, contentStreamMode: true); + + while (true) + { + var a = defaultLexer.NextToken(); + var b = contentLexer.NextToken(); + Assert.Equal(a.Kind, b.Kind); + Assert.True(a.Raw.Span.SequenceEqual(b.Raw.Span)); + if (a.Kind == TokenKind.EndOfInput) + break; + } + Assert.Equal(defaultLexer.Position, contentLexer.Position); + } +} diff --git a/tests/VellumPdf.Reader.Tests/PdfReaderDiagnosticCodeTests.cs b/tests/VellumPdf.Reader.Tests/PdfReaderDiagnosticCodeTests.cs index a5c84c6..bba71d2 100644 --- a/tests/VellumPdf.Reader.Tests/PdfReaderDiagnosticCodeTests.cs +++ b/tests/VellumPdf.Reader.Tests/PdfReaderDiagnosticCodeTests.cs @@ -36,6 +36,16 @@ public sealed class PdfReaderDiagnosticCodeTests [PdfReaderDiagnosticCode.PageAttributeInvalid] = 2, [PdfReaderDiagnosticCode.PageTreeNodeMalformed] = 2, [PdfReaderDiagnosticCode.PageTreeNodeLimitExceeded] = 2, + [PdfReaderDiagnosticCode.ContentStreamLexError] = 3, + [PdfReaderDiagnosticCode.UnknownOperator] = 3, + [PdfReaderDiagnosticCode.OperandStackMalformed] = 3, + [PdfReaderDiagnosticCode.FormXObjectDepthExceeded] = 3, + [PdfReaderDiagnosticCode.FormXObjectCycle] = 3, + [PdfReaderDiagnosticCode.FormXObjectBudgetExceeded] = 3, + [PdfReaderDiagnosticCode.ResourceMissing] = 3, + [PdfReaderDiagnosticCode.InlineImageMalformed] = 3, + [PdfReaderDiagnosticCode.ContentStreamTooLarge] = 3, + [PdfReaderDiagnosticCode.ContentLimitExceeded] = 3, [PdfReaderDiagnosticCode.DiagnosticsSuppressed] = 9, }; @@ -134,6 +144,16 @@ private static PdfReaderDiagnostic MakeDiagnostic(PdfReaderDiagnosticCode code) [PdfReaderDiagnosticCode.PageAttributeInvalid] = (205, PdfReaderDiagnosticSeverity.Warning), [PdfReaderDiagnosticCode.PageTreeNodeMalformed] = (206, PdfReaderDiagnosticSeverity.Warning), [PdfReaderDiagnosticCode.PageTreeNodeLimitExceeded] = (207, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.ContentStreamLexError] = (300, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.UnknownOperator] = (301, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.OperandStackMalformed] = (302, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.FormXObjectDepthExceeded] = (303, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.FormXObjectCycle] = (304, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.FormXObjectBudgetExceeded] = (305, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.ResourceMissing] = (306, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.InlineImageMalformed] = (307, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.ContentStreamTooLarge] = (308, PdfReaderDiagnosticSeverity.Warning), + [PdfReaderDiagnosticCode.ContentLimitExceeded] = (309, PdfReaderDiagnosticSeverity.Warning), [PdfReaderDiagnosticCode.DiagnosticsSuppressed] = (900, PdfReaderDiagnosticSeverity.Warning), }; diff --git a/tests/VellumPdf.Reader.Tests/PdfReaderOptionsTests.cs b/tests/VellumPdf.Reader.Tests/PdfReaderOptionsTests.cs index 80c3aba..f22b203 100644 --- a/tests/VellumPdf.Reader.Tests/PdfReaderOptionsTests.cs +++ b/tests/VellumPdf.Reader.Tests/PdfReaderOptionsTests.cs @@ -103,6 +103,37 @@ public void ToString_doesNotContainThePassword() Assert.DoesNotContain("correct horse battery staple", options.ToString()); } + [Theory] + [InlineData(0)] + [InlineData(33)] + public void MaxFormXObjectDepth_outsideRange_throwsArgumentOutOfRangeException(int value) + { + var bytes = Load("plaintext-baseline.pdf"); + + var ex = Assert.Throws( + () => PdfReader.Open(bytes, new PdfReaderOptions { MaxFormXObjectDepth = value })); + + Assert.Equal(nameof(PdfReaderOptions.MaxFormXObjectDepth), ex.ParamName); + } + + [Theory] + [InlineData(1)] + [InlineData(32)] + public void MaxFormXObjectDepth_atTheFloorOrCeiling_isAccepted(int value) + { + var bytes = Load("plaintext-baseline.pdf"); + + using var reader = PdfReader.Open(bytes, new PdfReaderOptions { MaxFormXObjectDepth = value }); + + Assert.NotNull(reader.Catalog); + } + + [Fact] + public void DefaultOptions_carryTheDefaultMaxFormXObjectDepth() + { + Assert.Equal(32, new PdfReaderOptions().MaxFormXObjectDepth); + } + private static byte[] Load(string name) { using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(name)