feat(reader): add an internal content-stream interpreter - #402
Open
Tim81 wants to merge 6 commits into
Open
Conversation
Lands the ISO 32000-2 section 7.8.2 operand-stack interpreter that PR 5 (text extraction) and PR 11 (image extraction) will share: Form XObject recursion with depth/cycle/budget guards, inline images per section 8.9.7, graphics and text state tracking, and a content-mode lexer relaxation for BX/EX compatibility sections. Malformed or unsupported content is reported through the diagnostics channel and interpretation continues, matching this reader's existing notify-and-continue policy rather than aborting the page. Public surface is minimal by design: PdfReaderOptions.MaxFormXObjectDepth (tighten-only, default 32) and nine new PdfReaderDiagnosticCode values in the 3xx block. The interpreter itself, its visitor interface, and the graphics/text state types stay internal until a real caller lands.
LooksLikeResyncPoint accepted any EI whose following bytes lexed without throwing, but a run of binary bytes outside the whitespace and delimiter sets lexes as one Keyword token, so a false EI inside DCT data followed by more image data passed the probe more often than not. The probe now also requires every keyword it sees to be an Annex A Table A.1 operator (or true/false/null); a test pins the binary-noise case. Also: drop the per-name UnknownOperator set, which the sink's (code, object, page) dedupe made redundant, and say so in the test; the CreateScope remark names its first caller; ReaderLimits.Resolve doc counts the fourth knob.
Do on a Form XObject now brackets the form's content in an implicit
q/Q, marked-content, and BX/EX save and restore (ISO 32000-2 section
8.10.1 steps a and e), using stack-depth floors rather than a real
push so a form's own state cannot leak into its invoker and the
implicit save costs nothing against MaxGraphicsStateDepth.
An unknown operator inside BX/EX now drops its operands per Table 33
("ignored without error", operands included), rather than only
suppressing the report.
The bounded resync probe for a false inline-image EI (LooksLikeResyncPoint)
now lexes at most 128 bytes and 8 tokens per candidate instead of the rest
of the buffer, fixing the quadratic scan (100 KB content: 18s before, well
under a second after) while accepting an unknown-but-printable operator or
a BX/EX section it previously rejected.
A failed tier-a/tier-b inline-image length now falls back to the EI scan
instead of losing the rest of the content stream, matching the existing
/L-past-the-end recovery path; an ID followed by CR LF consumes both bytes
as one separator per section 7.2.3, with a one-byte-earlier retry for a
binary payload whose first byte is itself LF.
Table 92's colour-space abbreviations now expand inside a /CS array
(the one composite inline colour space section 8.9.7 allows), and an
array led by /Indexed correctly counts one component.
The 64 MiB content budget is now a per-Run running total across the
page's own /Contents and every Form XObject it draws, not just the
page's own content; ContentStreamTooLarge and FormXObjectBudgetExceeded
now use ReportRetained so they survive MaxDiagnostics exhaustion the
same way PageTreeWalker's own walk-stop codes do.
Also: the Array.Copy overload in Concatenate copied a truncated
second stream over the first instead of appending it; MaxOperandsPerOperator
is 64 rather than 32, since Table 73's scn can legally need more than 32
for a DeviceN space; a new ContentLimitExceeded code separates this
reader's own processing ceilings from producer-side malformation, which
stays under OperandStackMalformed; UnknownOperator is Warning severity,
not Info; /Matrix, /BBox, and ExtGState's /Font now resolve through an
indirect reference before their shape check; a negative /L and an
invalid /W, /H, or /BPC are now reported instead of silently falling
through; and the fuzz test's try/catch around Open/GetPage no longer
also swallows a Run-time InvalidDataException, which surfaced a
pre-existing gap where resolving /Contents, a resource, or a Form
XObject could still throw past Run's own no-throw promise; Run now
catches that at the outermost level and reports it as a diagnostic.
The CHANGELOG bullet said the content budget "now covers" forms, but the interpreter is new in this release, so there is no earlier state for "now" to contrast with; it describes the budget as one per page instead. The OperandStackMalformed doc had a comma splice.
Charge the per-Run content-decode budget as each /Contents element or
Form XObject invocation decodes, not only after every element already
did: a /Contents array naming the same oversized stream many times no
longer holds every decode in memory before the cap gets a chance to
stop it, and a Form XObject invocation skips its own decode entirely
once the budget is already spent instead of decoding and discarding.
Give an over-cap 'q'/'BMC' push a credit a later 'Q'/'EMC' spends
before reporting an unbalanced pop, so a producer that legitimately
nests past this reader's own ceiling and balances every nest is not
also accused of an unbalanced pop. Clear the operand stack on every
unrecognised operator, not only inside a BX/EX section, per §7.8.2.
Save and restore the invoker's text matrices around a Form XObject
invocation, since the form's own content can open an independent text
object regardless of the invoker's state; report when 'Do' itself
occurs inside a text object. Concatenate a form's own /Matrix into the
CTM before interpreting its content (§8.10.1 b), so a visitor can read
the composed value. Check the Form XObject cycle guard before the
depth cap so the more informative code wins, and report a /XObject
entry that resolves but is not a usable Form or Image stream.
Replace the inline-image resync probe's single-window inconclusive
accept, which could mask a malformed stream once a token ran
past the first window, with a second bounded retry at a much larger
window before rejecting. Skip whitespace after ID for every filter
named anywhere in a /Filter array when ASCIIHexDecode or ASCII85Decode
is present, not only the one byte every other filter gets. Delay the
CR-as-EOL malformed report until after the one-byte-earlier retry it
exists for has also failed. Report a non-integer /L and reject a /BPC
outside {1, 2, 4, 8, 16} the same way an invalid /W or /H already is.
Fix two fabricated ISO 32000-2 quotations (Matrix's own §8.3.4 cm
citation, TextState's §9.4.1 citation) and several wrong table/clause
references, and correct doc comments that no longer matched what the
interpreter does.
The round-2 fix-up saved the invoker's text matrices around a Form XObject invocation but not the "inside a text object" flag, so a form invoked from within BT/ET had its own 'Do' judged against the invoker's state, and a form whose content opened BT without ET left the invoker looking as if it were still inside a text object. Save the flag, start the form's content outside any text object, and restore on return; two tests fail without the bracket and pass with it. The same fix-up described 'Do' as a general graphics state operator. ISO 32000-2 Table 50 lists it under XObjects; what forbids it inside a text object is §8.2 Figure 9, which admits no operator of that category there. Correct the comments, the diagnostic message and the code doc, and replace the paraphrased §8.10.1 b) quotation with the standard's own words.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #98 (v2.4 PR 3 of 16). #398 (page tree) is merged; nothing else outstanding.
What
An internal ISO 32000-2 §7.8.2 operand-stack interpreter over the page tree that PR 5 (text extraction) and PR 11 (image extraction) will share. Nothing public calls it in this PR; the public surface is one option and ten diagnostic codes.
Content/ContentInterpreter.cs:Run(PdfReadPage, IContentVisitor). Owns the operand stack (cap 64; Annex C.2 Table C.1 recorded 32 as the DeviceN colourant count earlier versions recommended and §8.6.6.5 allows an arbitrary number, soscnwith 32 components plus a pattern name is a legal 33-operand call), theTJarray cap (8192 elements), theq/Qstate stack (cap 64; an unbalancedQis ignored with a diagnostic),BX/EXnesting, marked-content nesting (cap 64, mirroring theq/Qtreatment),gswith an/ExtGState/Fontentry, and Form XObject recursion onDo(/Subtype /Form): depth fromPdfReaderOptions.MaxFormXObjectDepth(default 32, floor 1, tighten-only), an on-stack cycle guard (FormXObjectCycle, reported once), 4096 invocations per page (FormXObjectBudgetExceeded; the page still completes), and the §8.10.1 a)/b)/e) treatment of the form's content: the graphics state is saved, the form's/Matrixis premultiplied into the CTM (§8.3.4) soOnFormBeginand every operator inside the form see the composed value, and the state is restored on return. Marked-content depth andBX/EXdepth are saved the same way, implemented as stack floors so the implicit save costs nothing against the 64-deep cap and a form's ownQcannot pop below its entry; the invoker's text matrices and its inside-a-text-object flag are saved too, because the form's content starts outside any text object of its own (§9.4.1) and itsBT/Td/ETmust not leak back. An/XObjectentry that resolves to something other than a Form or Image stream (a non-stream, a stream without/Subtype, an unknown/Subtype) reportsResourceMissingwith the shape it found. A form without its own/Resourcesfalls back to the invoking stream's per §8.10.2 Table 93 (the PDF 1.1 shape; "optional but strongly recommended" since 1.2)./Matrix,/BBoxand the ExtGState/Fontresolve through an indirect reference before the shape check (§7.3.10)./CSarray too ([/I /RGB 1 <…>]becomes[/Indexed /DeviceRGB 1 <…>], the one composite inline colour space §8.9.7 allows). Delimiting in three tiers:/L(PDF 2.0, Table 91) when present and the bytes at the computed end areEI; else, for unfiltered data,Height × rowBytescomputed from/W,/H,/BPCand the component count of the colour space (named/ColorSpaceresources resolved through the page's resources; anything this reader cannot count falls through to the scan); else a whitespace-EI-whitespace/delimiter scan. A scan candidate is accepted when a probe over at most 8 tokens inside a 128-byte window after it lexes in content mode with every keyword either a Table A.1 operator,true/false/null, a one-byte{/}/>, or an unknown name made only of printable ASCII; a token that runs off that window is re-probed once over a 4096-byte window, and a token that runs off that one too rejects the candidate; a keyword carrying any other byte is binary noise and rejects the candidate;BIends the probe with acceptance because what follows itsIDis image data. When a tier-a or tier-b length does not land onEI, the interpreter reportsInlineImageMalformedand falls back to the scan rather than losing the rest of the stream. AfterID, CR LF counts as one EOL marker (§7.2.3), and whenASCIIHexDecodeorASCII85Decodeappears anywhere in the filter list every following whitespace byte is skipped before the data starts (§8.9.7 and its NOTE 2).JBIG2Decode,JPXDecodeandCryptinline areInlineImageMalformedand skipped; a missingIDlikewise; so are a negative or non-integer/L, a/Wor/Hthat is missing, non-integer or non-positive, and a/BPCoutside 1, 2, 4, 8 and 16 (Table 87).PdfLexergains an opt-in content-stream mode (new PdfLexer(data, contentStreamMode: true)):{,}and a lone>become one-byteKeywordtokens instead of throwing, so a stray brace insideBX/EXno longer aborts the page. The default constructor path is unchanged; the 11IConformanceRuleimplementations that construct aPdfLexer(one of them over CMap bytes rather than a content stream) keep their behaviour (Conformance.Tests below)./Contentsarrays are concatenated with a separator so a token split across two streams is not glued (§7.7.3.3); a non-stream element or a stream whose filter chain this reader cannot decode reportsContentStreamLexErrorand the page continues with what it has. Decoded content bytes are capped at 64 MiB perRun, one budget shared between the page's/Contentsand every Form XObject invocation it makes (each invocation counts again, since the cost being bounded is interpretation work). The budget is charged as each/Contentselement decodes, so an array that names one oversized stream many times stops decoding at the cap instead of holding every copy first, and a form invocation with no budget left skips its decode;ContentStreamTooLargeis reported once and the operators before the cap are kept.BX/EXreportsUnknownOperator(Warning) and clears the operand stack, since §7.8.2 says operands shall not be left over once an operator has run and a kept stack made the next valid operator fail its arity check with a second, spurious diagnostic. InsideBX/EXit is silent and its operands are dropped the same way, per Table 33 ("along with their operands").OperandStackMalformed(302) for producer-side malformations (unparseable or double-signed number, misplaced dictionary operand, wrong arity, an operand of the wrong type at the right arity such as aTJwhose operand is not an array (§9.4.3), aDoinside a text object (§8.2 Figure 9 admits no operator of Table 50's XObjects category there), unbalancedQorEMC) andContentLimitExceeded(309) for this reader's own ceilings (operand count,TJelements,qdepth, marked-content depth).ContentStreamTooLargeandFormXObjectBudgetExceededgo throughDiagnosticSink.ReportRetained(the feat(reader): walk the page tree and expose PdfReadPage #398 precedent for a condition that ends a walk): at most two retained entries perRun, one of each.GraphicsState,TextState,Matrix) live beside the interpreter; the text lane reads them, the image lane ignores them.PdfDocumentReader.CreateContentDiagnosticScope()is the first caller ofDiagnosticSink.CreateScope(Reader: a diagnostics channel for notify-and-continue conditions #385): one scope per page interpreted.Runnever letsInvalidDataExceptionescape: the lexer and parser paths were already caught per stream, and an outermost catch now covers a malformed reference chain met while resolving/Contents, a resource or a form (reported asContentStreamLexError).Public API (
PublicAPI.Unshipped.txt, +12 lines)PdfReaderOptions.MaxFormXObjectDepth(get/init) andPdfReaderDiagnosticCode300 to 309:ContentStreamLexError,UnknownOperator,OperandStackMalformed,FormXObjectDepthExceeded,FormXObjectCycle,FormXObjectBudgetExceeded,ResourceMissing,InlineImageMalformed,ContentStreamTooLarge,ContentLimitExceeded. All ten are Warning;UnknownOperatormoved from Info to Warning in round 1 becausePdfReaderDiagnosticSeverity.Infois defined as "produced correct output" and a skipped operator is not that.PdfReaderDiagnosticCodeTestspins the3xxrange, uniqueness and every severity.PublicAPI.Shipped.txtuntouched; the file is UTF-8 without BOM, LF.Departures from the brief, recorded for the reviewers
/Lkey. ISO 32000-2 §8.9.7 putsLin Table 91 ("Entries in an inline image object", the PDF 2.0 addition); Table 93 is the Type 1 form dictionary. The code cites Table 91.OperandStackMalformedand is dropped, and the stack is cleared. Since round 2 an unknown operator clears the stack as well, so1 0 0 1 10 20 R cm(the stray-Rcase) reportsUnknownOperatorforRand thenOperandStackMalformedfor acmwith no operands; thecmis dropped either way.UnknownOperatoris reported once per page, not once per name: the sink's dedupe key is (code, object, page) and the interpreter reports with no object number, so a second distinct unrecognised name on the same page is deduped away. The XML doc says so.ScanForEistrips exactly one whitespace byte before theEI; a producer that pads with more leaves the extra bytes in the image data. Unfiltered and DCT decoders tolerate trailing bytes; this is disclosed rather than modelled.q/Qcap rather than being separately justified; §14.6.1 sets no nesting limit.MaxFormXObjectDepthis public while the sibling caps are private constants: the plan's minimal-surface decision.Fix-ups on top of the first commit
99777c2:LooksLikeResyncPointoriginally accepted any candidateEIwhose following bytes lexed without throwing; binary noise lexes as one longKeyword, so the probe accepted almost everything. It then required every keyword in the window to be a Table A.1 operator;DctInlineImage_withAFalseEiFollowedByBinaryNoise_isSkippedByTheScanis the case the first version passed for the wrong reason. Also:ReaderLimits.Resolvedoc counts four knobs, theCreateScoperemark no longer says it is unused, the per-name set dropped, one comment reword.Round 1 (three reviewers at
99777c2: inline delimiting; operands and forms with a corpus sweep; lexer regression, API freeze and prose)Two HIGH, eleven MEDIUM, all confirmed and fixed in
3d98820(+ prose tidy298693e):Concatenateused the three-argumentArray.Copy, which copies to index 0, so a multi-stream/Contentsover the cap had its truncated last chunk written over the first. The single-stream test passed becausewrittenwas 0. NowArray.Copy(chunk, 0, capped, written, take);ContentExceeding64MiB_acrossTwoStreams_appendsTheSecondStreamAfterTheFirst_notOverIt.BI /F /DCT IDfollowed by N copies of" EI ("was quadratic (100 KB of content took 18 s, 400 KB 305 s, from a 1.2 KB Flate-compressed file). The probe is now bounded to 128 bytes and 8 tokens; a token that runs off a clipped window is inconclusive and accepts, so the" EI ("shape costs one string read by the outer lexer and ends inContentStreamLexError.ManyFalseEiCandidates_doesNotThrow_andReportsADiagnostic(asserts the diagnostic, not the time, per Tests: wall-clock assertions flake on the shared CI runner #400).Dodid not save and restore the graphics state (§8.10.1 a) and e)), so a form'scmleaked, a form's strayQpopped the page's save, and 70 invocations of aq-only form exhausted the cap. Marked-content andBX/EXdepth leaked the same way. Floors as described under What; fourDo_on…tests.BX/EXan unknown operator kept its operands (Table 33 says they are ignored with it).UnknownOperatorInsideBX_dropsItsOperands_perTable33./Contents; a 46 KB file drawing one 8.4 MB form 40 times interpreted 336 MB in 108 s with no diagnostic. Per-Runbudget shared with forms;FormDrawnRepeatedly_countsTowardTheSameContentBudgetAsThePage.EIfollowed by an unknown operator or byBX { } EX, losing the rest of the stream, and an 8-token window could swallow a second inline image with a short dictionary. Printable unknown keywords are neutral,BIstops the probe; fourFalseEiCandidate_…/TwoConsecutiveInlineImages_…tests.EI(/Lone short,ID+ CR LF,/W 0) abandoned the rest of the stream while/Lpast the end recovered. Symmetric fallback to the scan with a diagnostic; CR LF consumed as one separator per §7.2.3, with a one-byte-earlier retry for data that itself starts with LF; four tests./CS [/I /RGB 1 <…>]was not abbreviation-expanded and had no component count. Fixed;CsArray_tableNinetyTwoAbbreviationsExpand_andIndexedComponentCountDrivesTierB.ContentStreamTooLargeandFormXObjectBudgetExceededusedReport, so they were lost onceMaxDiagnosticswas spent (the feat(reader): walk the page tree and expose PdfReadPage #398ReportRetainedprecedent). Switched;ContentStreamTooLarge_isRetainedEvenOnceMaxDiagnosticsIsAlreadySpent.UnknownOperatorXML doc said "once per (page, operator name)"; it is once per page. Rewritten with the dedupe explanation.PdfLexer.cscomment inverted §7.2.3 (braces have their only syntax inside Type 4 function bodies, §7.10.5, outside content streams, and it cited §7.2.2); rewritten.Open,GetPageandRunin onetryadmittingInvalidDataException, so aRun-time throw was indistinguishable from an open-time one. Split;Runadmits onlyUnsupportedPdfFeatureException. The split exposed a pre-existing gap: resolving/Contents, a resource or a form through a malformed reference chain could throwInvalidDataExceptionpastRun. The outermost catch under What closes it; confirmed against the failing seed and a 30 000-iteration run.LOWs taken in the same commit:
/Matrix,/BBoxand ExtGState/Fontresolve indirect references;OnFormBegindocuments a nullboundingBox; operand cap 32 to 64 (Annex C.1); code 309 split from 302;UnknownOperatorInfo to Warning; §14.6.1 instead of §14.6.2 at the two nesting citations; reader-guide gotcha listsMaxFormXObjectDepth; negative/Land invalid/W//H//BPCreported; a second sign character rejected (the lexer already splits--5into two tokens, so the check is defensive and has no dedicated test);"> >"and a Seek-after-one-byte-keyword case inPdfLexerContentModeTests. The pre-existing §7.2.2 mentions elsewhere inPdfLexer.cswere not touched.The round-1 probe accepted a candidate when a token ran off a clipped window (more buffer beyond it) and treated the true end of a short buffer as the unbounded lexer would; round 2 replaced the accept with the second 4096-byte window described under What, because "ran off a clipped window" and "the token never closes" were indistinguishable from 128 bytes alone.
Round 2 (three reviewers at
298693e, same lenses)One HIGH, six MEDIUM, all confirmed and fixed in
b892675(Sonnet) and5f5f84b:AddElementdecoded every/Contentselement and held every chunk beforeConcatenateapplied the 64 MiB cap, so a 103 KB file whose/Contentsarray named one 20 MiB stream 128 times reached a 4.6 GB peak heap. The budget is now charged per element as it decodes and resolution stops at zero;ContentStreamsDecoded(internal) pins that ten references to one oversized stream decode only four elements. TheConcatenatecap stays as a backstop.HandleDodecoded the form before the exhausted-budget check (8 MiB form drawn 256 times allocated 6.9 GB). The check is hoisted above the decode./Lwhose data held an unclosed(straddling the 128-byte mark was delimited correctly at 100 filler bytes and truncated at 110, reportingContentStreamLexErrorand losing the trailingQ. Replaced by the two-window probe; tests sit on both sides of the boundary.ASCIIHexDecode/ASCII85Decodedata from the single-whitespace rule afterID, and NOTE 2 says the/Lcount starts after that whitespace; the code consumed one byte for every filter, so/L 48on an A85 image padded with two spaces was judged malformed and its payload re-lexed. Fixed as described under What.TjOperand_notAnArray_...already handled.ID, noEI, length past the end). Rewritten to say which.Matrix.csnow quotes §8.3.4 as written ("shall be multiplied before (premultiplied with) the existing transformation matrix");TextState.cscites §9.4.1, not §9.4.2, and quotes its own sentence ("may be specified only within a text object and shall not persist from one text object to the next").LOWs and nits taken: an over-cap
q/BMCpush now leaves a credit its matchingQ/EMCspends, so 65 balanced nests report 309 once and no 302; unknown operators clear the stack everywhere (What); the invoker's text matrices are saved aroundDoand aDoinside a text object reports 302; §8.10.1 b) applied andIContentVisitor.OnFormBeginsays the matrix is already in the CTM; the cycle guard runs before the depth cap so a self-referencing form atMaxFormXObjectDepth = 1reportsFormXObjectCycle; unusable/XObjectentries report instead of silently skipping; the CR LF retry runs before the 307 report; real-typed/Land/BPCoutside Table 87's set reported;ContentOperators.IsKnowntakes a span (no per-keyword string in the probe);DiagnosticSinkandIContentVisitordoc lines corrected; Annex C.2 Table C.1, Table 50 and the Table 351/352 note at their citations; three morePdfLexerContentModeTestscases (}5,}/N,}(s)); the 307 dedupe (one per page, so a second image's distinct malformation is folded into the first) is documented rather than keyed on offset.Declined, with the standard as the reason: the request to move the
ColorSpace-resource comment's citation from §8.6.3 to §8.6.8 Table 73. The rule thatCS/csname onlyDeviceGray,DeviceRGB,DeviceCMYKand somePatternspaces directly and everything else through theColorSpacesubdictionary is stated in §8.6.3 itself; Table 73 restates it and points back.5f5f84bcorrects two things in that fix-up: it describedDoas a general graphics state operator (Table 50 lists it under XObjects; §8.2 Figure 9 is what excludes that category from a text object), and it saved the text matrices around a form but not the inside-a-text-object flag, so a form invoked from withinBT/EThad its ownDojudged by the invoker's state and a form ending in an unclosedBTleft the invoker inside a text object. Both tests fail without the bracket. The §8.10.1 b) quotation it added was a paraphrase and now reads as the standard does.Tests
ContentInterpreterTests(91: operand shapes, caps at the boundary with the 302/309 split,q/Qandcm, text-state operators,gsfont, forms at depth 32 and 33, cycle, 4097 invocations, resources fallback, matrix and BBox handed over directly and by reference, the four form save/restore cases, the shared content budget, the three inline-image tiers with their fallbacks, the false-EIcases (binary noise, unknown printable operator,BX/EX, short second dictionary, a terminated string straddling the 128-byte window, unterminated literal and hex strings rejected at every length on both sides of it and past the 4096-byte window, a terminated 4000-byte literal after a realEI, 20 000 candidates), the AHx/A85 whitespace rule, the CR LF retry ordering, a real-typed/L,/BPC 3, the budget charged per/Contentselement (ten references to one 20 MiB stream decode four, count pinned) and an 8 MiB form skipped from its ninth invocation once the budget is spent, the over-capq/BMCcredit, unknown operators clearing the stack, the form/Matrixin the CTM,Doinside a text object with the form judged on its own state and an unbalanced formBTnot leaking out, cycle before depth atMaxFormXObjectDepth = 1, the three unusable/XObjectshapes, abbreviation KATs incl. the/CSarray, named colour spaces, JPX inline, missingID, negative/L, invalid/W, split tokens, non-stream/Contents, image-filtered/Contents, the 64 MiB cap in one and two streams, retention pastMaxDiagnostics, and a CsCheck run over byte-mutated content streams assertingRunnever throws and always terminates),PdfLexerContentModeTests(27 cases: default mode still throws on{,}, lone>; content mode yields keywords; every other token kind identical across modes),PdfReaderDiagnosticCodeTests,PdfReaderOptionsTests.Gates (local,
REQUIRE_ORACLES=1,REQUIRE_VERAPDF=1,QPDF_HOME,POPPLER_HOME,VERAPDF_HOMEset) at5f5f84bdotnet build VellumPdf.slnx: 0 warnings, 0 errorsdotnet format --verify-no-changes: cleaneng/clean-room-check.ps1: passed--againstorigin/mainMIN_VALID_LINESis unchanged in this PR and is re-measured after PR 9 and PR 11 per the plan).Review lenses
/L, the computed length (component counts,/BPCpacking,/IMmasks),EIinside DCT data, theIDwhitespace rule, the bounded probe.Rconsequence above) and Form XObject bombs: depth, cycle, budget, the shared byte budget, the implicit save/restore, the resources fallback,/Matrixand/BBoxshapes.PdfLexerconsumer unchanged, the mode opt-in) and the API-freeze checklist onMaxFormXObjectDepthand the ten codes.