feat(reader): walk the page tree and expose PdfReadPage - #398
Merged
Conversation
…ibutes PdfDocumentReader.PageCount, Pages, and GetPage walk /Root -> /Pages -> /Kids (ISO 32000-2 §7.7.3) iteratively, on first access, and cache the result. PageCount is what the walk finds, never a node's own /Count: §7.7.3.2's own NOTE calls that entry redundant with the tree structure, and producers in the wild disagree with their own /Kids often enough that trusting /Count would misreport ordinary files, not just adversarial ones. Inheritance of /Resources, /MediaBox, /CropBox, and /Rotate (§7.7.3.4) resolves against the walker's own ancestor stack, never by following a page's own /Parent — a forged /Parent must not be able to redirect inheritance away from the chain the walk actually descended. A depth cap of 256 (matching PreflightContext.WalkPages's existing precedent) and a 100,000-leaf cap bound a hostile tree; a repeated indirect reference to the same node or page object — forbidden outright by §7.7.3.2/§7.7.3.3 — is treated as a cycle and skipped rather than followed forever. Six new PdfReaderDiagnosticCode values in the 2xx (page tree) block report every one of these conditions through #385's diagnostics channel instead of throwing, and this is the first walk to populate PdfReaderDiagnostic.PageIndex. A page tree the walk cannot use at all (missing or non-dictionary /Pages, most commonly) leaves PageCount at 0 with one PageTreeMissing report rather than raising an exception. Part of #98.
Three reviewers found gaps in the page-tree walker landed for #98: - A cycle guard keyed only on indirect-reference object numbers missed a direct-dictionary node whose /Kids array pointed back at itself, letting a 3-object file recurse exponentially and never return. The guard now also tracks a /Kids array's own object number when reached indirectly, and a 1,000,000-element total-kids-examined budget (PageTreeNodeLimitExceeded) bounds the walk directly, since branching makes work exponential in depth, not linear. - Every ResolveValue call in the walker now goes through a TryResolve wrapper that treats a malformed indirect target (InvalidDataException) as unresolvable instead of letting it escape PageCount/Pages/GetPage and take every page down with it. - Node/leaf classification now looks at /Type first: a /Type /Pages node with no usable /Kids, a /Type /Page leaf with a stray /Kids, or any other /Type reports PageTreeNodeMalformed instead of being silently misclassified. - CropBox is intersected with MediaBox per §14.11.2.1 instead of exposed as written; an empty intersection falls back to MediaBox with a diagnostic. - A malformed own MediaBox/CropBox/Rotate now continues up the ancestor chain looking for a valid value instead of jumping straight to the Letter/0 default, naming the object that supplied each malformed value along the way. - Dropped the manual depth-exceeded once-flag (the diagnostic sink already dedupes); split the fuzz harness's page-tree assertions into their own try/catch so a resolve-loop failure no longer skips exercising the walk. Also: reworded several doc comments that overstated or understated what the spec requires (Kids indirection, /Count's normative role, Rotate accepting an integral real), dropped /Resources from the public capability-table row now that it's internal, and corrected the CHANGELOG bullet to list all eight new diagnostic codes instead of implying two were pre-existing.
Rewrote every em dash introduced by the previous commit's new or changed lines (comments and doc comments only) with commas, periods, colons, or parentheses; dashes already present before that commit are untouched.
…und attribute resolution PdfObjectParser.ParseReal now rejects an out-of-range real literal (310+ integer digits, which double.TryParse silently turns into Infinity) with InvalidDataException instead of letting PdfReal's constructor throw ArgumentException, a type PageTreeWalker.TryResolve does not catch; that let such a literal escape PageCount/Pages/GetPage entirely. The page-walk block in ParserFuzzTests now only accepts UnsupportedPdfFeatureException, matching that contract instead of the broader harness catch. Inherited-attribute resolution (MediaBox/CropBox/Rotate/Resources) is now O(nodes) instead of O(nodes x depth): each page-tree node computes its own effective values once, when its walk frame is pushed, instead of every leaf below it re-scanning the whole ancestor chain. A malformed attribute on an ancestor is reported once, against that node, rather than once per descendant leaf. The page-tree root is now classified the same way as any other node reached through /Kids: a root that is really a leaf, or names an unrecognised /Type, reports PageTreeMissing instead of being walked as if it had zero children. A node whose /Kids resolves to a present but empty array, root included, still yields zero pages with no diagnostic, since an empty tree is a valid zero-page document under ISO 32000-2 section 7.7.3. Other fixes: - IntersectWithMediaBox now uses a strict "<" so a zero-width or zero-height CropBox that still touches MediaBox is kept as written, matching the section 7.9.5 NOTE, rather than replaced. - PdfReadPage.ObjectNumber is now int? (null for a direct /Kids dictionary) instead of overloading 0, matching PdfReaderDiagnostic.ObjectNumber's shape. - Diagnostic messages naming a direct (object-number-0) source now say "a direct page-tree node" or "the page dictionary" instead of "object 0". - Doc comments and docs/reader-guide.md updated for all of the above.
The page-tree feature commit added 35 em dashes across comments, XML docs, the guide and the CHANGELOG; the fix-up commits had already been swept. Reword them as commas, colons or separate sentences so the branch adds none over its base. No code change.
This was referenced Sep 2, 2026
MaxDiagnostics could suppress the one report that says a page list is incomplete on exactly the document engineered to hit both a diagnostics cap and a walk cap. DiagnosticSink.TryAccept checked the cap before the walk-stopping codes were even discovered, so a document with enough distinct malformed-kid conditions to fill the cap could reach the leaf, node, or depth budget with no PageTreeLeafLimitExceeded, NodeLimitExceeded, or (first) DepthExceeded report at all. ReportRetained bypasses the cap for exactly those three call sites, each reported at most once per walk. Several malformed attributes on one object used to collapse to one diagnostic silently, since the sink dedupes by (code, object, page): PageTreeWalker now collects every failing attribute on an object first and reports once, naming all of them, instead of losing all but the first Report call. A node that also carries /Contents now reports PageTreeNodeMalformed the same way a /Type /Page object with a stray /Kids already did, instead of being walked silently. Pins the inline-huge-literal shape (whole object lost to a parse failure, not just the one attribute) with two tests, and fixes several ISO citations a conformance-lens review round flagged: Table 31 admits /Type /Template but it can never be a /Kids child (§12.7.7 forbids it a /Parent, Table 31 requires one); /Root/Pages' root-node requirement is Table 29's, not just §7.7.3.2's; the zero-width CropBox case is the collapsed intersection, not the bytes verbatim; and /Resources is Required unconditionally (an empty dictionary is the spec's own accommodation for a page that draws nothing), not conditional on a page having content. Also fixes a CI flake in ManyDistinctEmptyNodes_stopsAtTheKidsExaminedBudget: its 1,000,001-object fixture cost 33-52s on a shared runner purely from cross-reference and object-parsing overhead, tripping the test's own wall-clock ceiling twice. Kids are now direct dictionaries embedded inline rather than indirect objects, cutting the run to ~2s; the leaf-cap test gets the same treatment for the same reason. The wall-clock assertions on both are dropped as no longer needed to catch a regression.
ISO 32000-2 §7.3.9 makes an explicit /Key null equivalent to the key being absent, but the page-tree walker's presence checks used a bare `raw is null`, which PdfDictionary.Get never returns for a null-valued entry (it hands back the PdfNull singleton). /Contents null on a node, /Kids null on a /Type /Page object, and /MediaBox null /Rotate null on a leaf each produced a spurious diagnostic; conversely /MediaBox null on an ancestor silently counted as "the chain has one", suppressing the leaf's own missing-MediaBox report. A shared IsAbsent helper (mirroring Filters.cs's existing /Filter null handling) now backs every presence check in the walker. The root page-tree node reported no PageTreeNodeMalformed for a stray /Contents entry, since Walk's root path never goes through ClassifyNode; it now runs the same check the root's own copy carries. Also corrects several ISO citations a conformance review pass flagged: Table 31's Parent row itself exempts /Type /Template from carrying one, rather than the reader inferring a tension between two separate rules; the /Contents-on-a-node message cites what actually makes the entry anomalous (a Table 31 page-object key with no inheritance path under §7.7.3.4) instead of Table 30's silence, which every optional key shares; the disjoint-CropBox message no longer claims §14.11.2.1 mandates the MediaBox substitution it only makes as its own fallback; and the stray-/Kids-on-a-page message cites Table 30's own Kids row instead of a Type row that also admits Template. Corrects the retained- diagnostics count from three to two per walk (the leaf and node limits each end the walk immediately, so at most one of that pair ever fires alongside the first depth report) everywhere it was overstated.
ISO 32000-2 §7.3.9 has two absence rules, and IsAbsent only implemented one: a direct null value. A reference to a nonexistent object, or to an object whose own body is null, is the same absence under the clause's other sentence, but IsAbsent tested the raw unresolved value, so /Contents 9 0 R with 9 free still reported PageTreeNodeMalformed, /MediaBox 9 0 R on a leaf still reported PageAttributeInvalid instead of inheriting silently, and so on. ResolveOrAbsent now resolves once (reusing the result instead of resolving a second time downstream) and treats a resolved null or a resolution to nothing as absent too, while still treating a reference the parser could not resolve at all as present, since that object exists and just could not be parsed, not "nonexistent" in the spec's sense. The catalog's own /Pages lookup gets the same fix, so /Pages null now reports "no /Pages entry" instead of "does not resolve to a dictionary". Three stray-/Contents test fixtures used a dangling reference as their "present" case, which the fix above makes absent; they now point at a direct empty array instead. New tests cover the indirect shapes: a dangling /Contents and /MediaBox, a reference to an emitted null object, and /Pages null on the catalog. Also fixes a self-contradicting PageTreeNodeMalformed doc that called the root a page-tree node and then said it did not classify as one, and listed a case (no usable /Kids) that does not apply to the root at all; restructures the PageAttributeInvalid doc out of a nested either/or; and adds a Changed entry for the round-3 ParseReal fix (6a03b6f), which is reachable by any real-number resolution, not just the page walk that first exposed it.
ISO 32000-2 section 7.3.10's 2020 NOTE permits chains of indirect references with semantics equivalent to a direct value, but the walker's resolver only ever followed one hop, so a /Pages, /Contents, or /MediaBox entry reached through a reference to a reference was misreported as missing or malformed even though it named a perfectly good value one hop further on. Every resolve site in the walker now follows a chain up to a 32-hop cap, detecting cycles per chain. The reader's own resolve cache never remembers a failed resolution, so before this a shared object that fails to resolve was reparsed once per node pointing at it - quadratic in node count on crafted input (measured locally: roughly 16s at 2000 nodes before, under 50ms after). A walk-local negative cache now remembers, for the rest of one walk, which objects already failed and why, so a repeat costs a lookup instead of a reparse. Also resolves /Type through the same chain-following path (an indirect /Type value was previously read as though absent), names the object number when the catalog's /Pages entry points at something that does not exist rather than reusing the generic "no /Pages entry" message, and corrects several comments this touched in passing: a stale claim about what Filters.cs already handles, an ArgumentException-vs-InvalidData citation to the wrong Annex clause, two page-tree-guide sentences that no longer matched the code once the classification path was clarified, and test fixtures that used an empty Contents array Table 31 forbids writers from producing.
The round-6 fix-up introduced four " -- " dash substitutes in comments, which the branch had kept at zero alongside em dashes. The catalog /Pages message also claimed the referenced object "does not exist"; with reference chains now followed that object may well exist and merely lead to nothing, so the text says what the entry resolves to instead.
Chain-following resolve meant two different /Kids entries could each be an alias, itself a reference to a reference, that lands on the same page or node several hops later. TryResolve and ResolveOrAbsent now also report the object number of the last reference actually followed, and the walker uses that terminal identity, not a raw single-hop read of each /Kids element, for the repeat guard, the /Kids-array guard, the PageTreeCycle report, and PdfReadPage.ObjectNumber. Before this, two aliases of one page produced two pages sharing a single dictionary instance with no diagnostic, and a fan-out built from aliased nodes could double its own work at every level instead of being caught immediately. Also fixes two bugs the chain-following change exposed: the hop cap inspected the wrong iteration's result, so a chain needing exactly 32 resolves failed even though the 32nd one landed on a usable value, and the per-chain cycle guard was keyed on object number alone, so a hop from one generation of an object to another looked like a cycle and swallowed the reader's own ObjectGenerationMismatch report. Corrects a spliced ISO 32000-2 §7.3.10 quotation that attributed a separate normative paragraph to the 2020 NOTE, a stale bound on the negative cache's own size, an inaccurate "quadratic" description of the pre-fix cost this cache replaced, and the CHANGELOG's claim about which caller throws for an out-of-range real literal.
FailedResolveCache's own doc claimed its size was bounded by the xref table, but TryResolve adds a key on any resolve, including a reference the xref has no entry for; measured with a crafted /Kids array of 300000 distinct nonexistent targets against a 4-object xref, which grew the cache to 300000 entries. Replace that bound with the one that actually holds: distinct (object number, generation) pairs the walk resolves, xref-resident or not. CHANGELOG's list of diagnostics an overflowing real literal can produce during the page-tree walk was missing PageTreeNodeMalformed, which fires for the same literal reached through a non-root node's own /Kids or /Contents entry, and its "the one caller" phrasing is contradicted by object-stream member resolution and SaveDecrypted, which already recover from the same exception per object. The negative-cache test's comment carried wall-clock numbers from a different, much larger failing target than its own fixture uses; describe the mechanism (a fixed-size reparse repeated once per node) instead of numbers this fixture never produced. Also: the 20-level alias fan-out test's comment claimed an exponential blowup the fixture does not exhibit (measured against the pre-fix commit: 2 pages, 38 cycle reports, no node-limit hit, 23 ms) and describes the wrong-answer defect the test actually pins instead; two spliced ISO 32000-2 quotations restored to what the spec says; a duplicated section banner; an orphaned comment line; two intensifiers; a reader-guide sentence on how PageTreeCycle covers a repeat reached through different aliases of the same object.
TryResolve's own doc named SaveDecrypted as per-object recovery precedent, but every catch (InvalidDataException) in PdfDocumentReader.SaveDecrypted.cs rethrows through WrapResolveFailure, failing the whole operation rather than recovering; and the normal object-stream member path, ResolveFromObjectStream, has no catch at all. Read every cited site in PdfDocumentReader.SaveDecrypted.cs, PdfDocumentReader.cs, and XrefReconstructor.cs before rewriting: the sites that genuinely recover per object are TryParseObjectStreamMemberDirect (returns null instead of throwing) and reconstruction's own scan in XrefReconstructor.cs (charges and resumes past a failed candidate). Also: FailedResolveCache's doc overstated its own population as every pair the walk resolves, when cache.Add only ever runs on a failure path, so a target that resolves successfully adds nothing. PageTreeCycle's doc gained the alias nuance the guide already carries. The two ISO clauses behind the repeated-reference rule apply to different objects, one to page tree nodes and one to page objects, not jointly to both as the prior wording implied. The guide no longer implies a page-tree node appears in the page list. A few ragged line wraps left over from the previous commit are reflowed.
The visited comment's split of the joint ISO clause left "describe Kids as a tree" without a plural subject. Ended the two-clause sentence after the alias wording and started a new one, "Both clauses describe Kids as a tree", instead of remerging into "each forbid". Applied the same distributive fix to PageTreeCycle's XML doc and to the runtime message this walker reports for the repeat itself, since both still said each ISO clause covers both object kinds. FailedResolveCache's own doc said its population is every pair the walk fails to resolve. Narrowed that to the three cache.Add call sites: a resolution that throws, cycles, or lands on nothing. A chain that exhausts the hop cap without terminating also returns before reaching Add, so it belongs in the same narrowed sentence. For the TryResolve precedent citing XrefReconstructor.cs, added "not an xref stream" to the parenthetical, since three of its four InvalidDataException sites recover unconditionally and the fourth only while the reconstruction budget holds; the added words keep the sentence from reading as if it covers all four. The reader guide's sentence on a shared node's descendant pages now carves out the one exception a caller does still see: the PageTreeCycle diagnostic itself.
This was referenced Sep 3, 2026
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. Second PR of the v2.4 milestone (plan §B); the first consumer of the diagnostics channel from #395 that populates
PageIndex.Why
The Reader had no page tree walk, no page type and no resource accessor: everything text and image extraction needs to know about a page (its content, its
/Resources, its/MediaBox, its rotation) was unreachable from the public surface. The only walker in the tree isPreflightContext.WalkPagesin Conformance, which resolves inherited attributes by chasing/Parent; a forged/Parentredirects that, so the Reader gets its own walker that inherits from the ancestors it actually descended through.What
internal sealed class PageTreeWalker(Reader/Pages/): iterative DFS over/Root→/Pages→/Kids, depth cap 256,HashSet<int>cycle guard shared by intermediate nodes, page leaves and indirectly reached/Kidsarrays (ISO 32000-2 §7.7.3.2 forbids multiple indirect references to the same page tree node, §7.7.3.3 the same for a page object; since round 7 the key is the object number the reference chain ends on), hard cap 100 000 leaves,/Count-agnostic. Inherits exactly/Resources,/MediaBox,/CropBox,/Rotatefrom the walker's own ancestor stack (§7.7.3.4); nearest ancestor wins, the page's own entry wins over all. Lazy (first access ofPageCount/Pages/GetPage), cached for the reader's lifetime; never runs in the constructor.PdfDocumentReader.PageCount,Pages(IReadOnlyList<PdfReadPage>),GetPage(int)(ArgumentOutOfRangeExceptionoutside[0, PageCount));PdfReadPage(sealed, no public constructor) withIndex,ObjectNumber(int?, null for a direct/Kidsdictionary),Dictionary,MediaBox,CropBox,Rotate.Resourcesis internal in 2.4 by maintainer decision.PdfRectangleis Kernel's existing type; no new geometry type.MediaBoxcorners ordered; a missing or malformed/MediaBoxfalls back to Letter withPageAttributeInvalid;CropBoxdefaults toMediaBoxwhen absent (§7.7.3.3);/Rotatefolded to 0/90/180/270, integer-valued reals accepted, a non-multiple of 90 falls back to the nearest valid ancestor value (0 when the chain has none) plusPageAttributeInvalid(round 1). The diagnostic'sObjectNumbernames the node that supplied the malformed value. When that node is the page itself,PageIndexnames the page; when it is an ancestor,PageIndexis null and the entry is reported once for the node rather than once per descendant page (round 3).PageTreeMissing= 200 (Error:PageCountis 0, nothing partial survives, the same patternUnknownFilteruses),PageTreeCycle= 201,PageTreeDepthExceeded= 202,PageTreeLeafLimitExceeded= 203,PageTreeKidNotDictionary= 204,PageAttributeInvalid= 205,PageTreeNodeMalformed= 206,PageTreeNodeLimitExceeded= 207 (all Warning).PdfReaderDiagnosticCodeTestsextended for value range, severity and uniqueness./Root, a missing or non-dictionary/Pages, or a root node whose/Kidsis missing or not an array →PageCount == 0plusPageTreeMissing. A non-root/Type /Pagesnode with no usable/KidsreportsPageTreeNodeMalformedand contributes no children (round 1); an untyped dictionary whose/Kidsis not an array has no structural tell left and is classified as a leaf, so it contributes one page.src/VellumPdf.Reader/README.mdanddocs/reader-guide.md(byte-identical block), a guide subsection, CHANGELOG under### Addedplus one### Changedentry for theParseRealchange (round 5).Tests
PageTreeTests(52 cases atb6f2785; 23 ateb36429): writer-built 3-page document (indices, object numbers,MediaBox, both out-of-rangeGetPagecalls); nested intermediates in document order;/Countlying low and high; inheritance from the root, from the nearest intermediate, and the page's own override; forged/Parentpointing at an unrelated dictionary; a kid array containing its own ancestor (terminates, pages before the cycle kept,PageTreeCyclenames the object); a 300-deep chain (PageTreeDepthExceeded, pages under the cap kept); 100 001 leaves (PageTreeLeafLimitExceeded; 0.67 s ateb36429, 0.19 s after the round-3 rebuild with direct leaves); missing/Pages, non-dictionary/Pages, non-array/Kids, integer kid; reversedMediaBoxcorners, 3-elementMediaBox, absentCropBox,/Rotate450 / -90 / 45 /90.0; laziness (no page-tree diagnostic untilPageCountis read); the encryptedenc-aes-128-emptyuser.pdffixture through the decrypting resolver.ParserFuzzTestsnow readsPageCountand iteratesPagesper mutated seed.Mutation checks: with the cycle guard disabled the cycle test fails (128 pages, bounded only by the depth cap); with the ancestor lookup disabled the inheritance, forged-
/Parentand leaf-cap tests fail. Both restored.Review fix-ups (round 1)
visitedset, so a/Kidsarray shared by indirect reference across many direct-object nodes produced an exponential walk that the depth and leaf caps could not stop. Two guards: the walker now records/Kidsarray object numbers reached by indirect reference invisited(a shared array is reported asPageTreeCycleon its second visit, 0.039 s on the fixture that took the round-1 walk past the review's patience), and a work budgetMaxKidsExamined = 1_000_000stops any walk built from distinct objects withPageTreeNodeLimitExceeded(207), measured at about 6 s on 1 000 000 distinct empty nodes with no cycle involved.InvalidDataExceptionfrom a malformed attribute (a 40-digit/Rotate, a/MediaBoxelement that is not a number) escapedGetPage; every attribute now goes throughTryResolveper object, and a malformed own attribute falls back to the nearest valid ancestor value before the Letter convention, withPageAttributeInvalid./Type-first (Table 30 and Table 31 both make/TypeRequired):/Type /Pagesis a node,/Type /Pagea leaf, a missing/Typeis classified by the presence of/Kids, and a dictionary that is neither reportsPageTreeNodeMalformed(206) and is skipped. A node with/Kidsthat is not an array reports the same code instead ofPageTreeKidNotDictionary.CropBoxis intersected withMediaBoxper §14.11.2.1 ("shall"); an empty intersection falls back toMediaBoxwithPageAttributeInvalid.ObjectNumberdoc: a page reached as a direct kid (permitted nowhere by Table 30, tolerated by the walker) reportsObjectNumber == 0and no diagnostic (round 2 changed this tonull);Dictionaryis the live parsed dictionary, not a copy.Dictionaryis the same instance the resolver returns for its object number, andPages[i].Index == ifor every page.2xxcodes, quote Table 30's "definitively determines the number of descendant pages" as the reason/Countis ignored, and give the classification rules, the inherited-value fallback order and the intersection;/Resourcesdropped from the capability row since the property is internal in 2.4 (README and guide stay byte-identical).ParserFuzzTests: the page-tree block runs in its own try/catch and readspage.Dictionary, so a fuzz failure in the page walk is attributed to it rather than to the xref parse.093709a, measured againsteb36429only; round 2 measured the whole branch, see below.Reader.Tests at
093709a: 1074 total, 0 failed, 40 skipped (local oracle skips); Conformance.Tests atf85a425with veraPDF required: 1259 total, 0 failed, 282 skipped (the pdftotext and qpdf oracle tests, not required in that run). Build, format and clean-room checks green at093709a.Review fix-ups (round 2)
PdfObjectParser.ParseRealaccepted a real literal of 310 or more integer digits, whichdouble.TryParseturns into Infinity, andPdfReal's constructor then threwArgumentException, a typePageTreeWalker.TryResolvedoes not catch; a/Rotateor/MediaBoxelement of that shape escapedPageCount,PagesandGetPage.ParseRealnow rejects a non-finite result withInvalidDataException, which is what every other malformed token throws, and the page-walk block inParserFuzzTestsaccepts onlyUnsupportedPdfFeatureExceptionso the fuzz contract matches the documented one instead of the harness's broader catch./MediaBox,/CropBox,/Rotateand/Resourcesonce when it is pushed, and a malformed ancestor attribute is reported once against that node rather than once per descendant page./Typewas treated as a node with zero children and no diagnostic. It now goes through the same/Type-first classification as any node reached through/Kidsand reportsPageTreeMissing. A present but empty/Kids, root included, still yields zero pages and no diagnostic: §7.7.3 allows a zero-page document.IntersectWithMediaBoxused<=, so a zero-width or zero-heightCropBoxthat touchedMediaBoxwas replaced; the §7.9.5 NOTE keeps such a rectangle as written, and the comparison is now strict.PdfReadPage.ObjectNumberisint?(null for a direct/Kidsdictionary) instead of overloading 0, the shapePdfReaderDiagnostic.ObjectNumberalready has; the two diagnostic messages that named "object 0" now say "a direct page-tree node" or "the page dictionary"./Kidselement that resolves to a stream keeps its behaviour: the resolver hands the walker the stream's own dictionary, and the untyped-dictionary rule classifies it as a leaf.ClassifyNode's doc comment now says so, since the round-2 LOW read it as an oversight.b6f2785is a style-only sweep (33 lines in eight files, no code change, the capability-table blocks stay byte-identical); the branch diff againstorigin/mainnow adds 0 and removes 5.Reader.Tests at
b6f2785with oracles required: 1091 total, 0 failed, 12 skipped (the pre-existingThirdPartyreconstruction cases);PageTreeTests52 total, 0 failed; Conformance.Tests 1259 total, 0 failed, 0 skipped; Cli.Tests 899 total, 0 failed. Build, format and clean-room checks green atb6f2785.Review fix-ups (round 3)
MaxDiagnosticswas reached, the three codes that say the page list is incomplete (PageTreeLeafLimitExceeded,PageTreeNodeLimitExceeded,PageTreeDepthExceeded) were dropped like any other report, so the document most in need of that warning was the one that never showed it.DiagnosticSink.ReportRetainedrecords past the cap (deduped by the same key, forwarded to the parent scope as the same instance, and never counted toward the cap, which is what the new_ordinaryCountfield is for). The walker uses it for the leaf and node limits and for the firstPageTreeDepthExceededof a walk; later depth reports are ordinary.PdfDocumentReader.Pageswalks once per reader (_pages ??=), so at most two retained entries exist per reader: the leaf and node limits each end the walk at once, so only one of that pair can fire alongside the first depth report (round 4 corrected this from three). SixDiagnosticSinkTestspin it (three at a cap of 1, the others at 2 and 10), and threePageTreeTestsopen a document withMaxDiagnostics = 2and enough dangling kids ahead of the limit that the cap is full before the walk stops.PdfReaderDiagnostic.PageIndexand the guide saidPageAttributeInvalidreports carry the page index; the ones raised against an ancestor node carrynull, since no leaf has been reached. Both now say so and tell a caller filtering by page index to look at null-index reports too./MediaBox,/CropBoxand/Rotatereported the first and lost the other two to the sink's(code, object, page)dedupe.ResolveRectangleAttribute,ResolveRotateAttributeandIntersectWithMediaBoxappend their text to a per-object list thatBuildPageandComputeEffectiveAttributesreport as a single message; a single failure's message text is unchanged, except the disjoint-CropBox text that round 4 reworded (below). This also corrects a round-1 bullet: the disjoint-CropBoxreport used to carry no object number (IntersectWithMediaBoxreported withnull); it now names the object like the others./Contentsalongside/Kidson a node is reported asPageTreeNodeMalformed(Table 30 lists no such key) and the node is still walked as a node; a node with both problems gets one report with both parts./Type /Templateas a/Kidschild is skipped withPageTreeNodeMalformed: Table 31's own Parent row says a Template object has no/Parent(a rule §12.7.7 repeats), so it can never be a legal child (round 4 corrected the citation, below)./Rotateloses that leaf asPageTreeKidNotDictionary; on the root it loses the tree asPageTreeMissing./Root/Pagesand §7.7.3.2 for what a node is; thePageAttributeInvaliddoc separates §7.9.5 (rectangles) from §7.7.3.3 (the/Rotaterule); the CropBox zero-width wording says the intersection is kept, not the crop box's own bytes; the visited-set comment no longer says the spec forbids a shared/Kidsarray; a/Rotate 360case joins the normalisation theory.PdfReadPage.Dictionaryis a mutablePdfDictionary(the same shape as the shippedCatalog, documented read-only),Rotatestaysint, and there is noGenerationonPdfReadPage.ManyDistinctEmptyNodes_stopsAtTheKidsExaminedBudget_withNoCycleInvolvedtook 51.8 s and 32.9 s on the runner against a 30 s ceiling because its 1 000 001 nodes were indirect objects, each parsed through the xref. The kids are now direct dictionaries inside the root's/Kids, which is the shape the budget guards against (the walk, not the parse), and the wall-clock assertion is gone: 1.6 s standalone.LeafCap_stopsAt100000_andReportsTheLimitgot the same treatment (0.19 s); the 2 s and 5 s ceilings on the shared-kids and deep-chain tests stay, at 3 and about 5 200 objects.Reader.Tests at
2b91eaewith oracles required: 1108 total, 0 failed, 12 skipped;PageTreeTestsandDiagnosticSinkTeststogether 88 total, 0 failed. Build, format and clean-room checks green at2b91eae; branch diff againstorigin/mainadds 0 em dashes and removes 6.Review fix-ups (round 4)
/Parentthat Table 31's Parent row itself resolves ("Objects of Type Template shall have no Parent key"). The walker comment, the diagnostic doc and the guide now cite that row, with §12.7.7 as the rule that repeats it.nullvalues are absent (§7.3.9).PdfDictionary.Getreturns thePdfNullsingleton for a/Key nullentry, and the walker's presence checks testedraw is null, so/Contents nullon a node,/Kids nullon a/Type /Pageobject and/MediaBox nullor/Rotate nullon a leaf each raised a spurious diagnostic, while/MediaBox nullon an ancestor counted as "the chain has one" and silenced the leaf's own missing-MediaBox report. A sharedIsAbsenthelper (thePdfNullspecial caseFilters.csalready makes for/Filter null, without its Info diagnostic) now backs every presence check; four tests cover the four spurious cases (MediaBox and Rotate share one) and the suppressed one./Contentslike any other node.Walk's root path never went throughClassifyNode, so/Root/Pageswith/Contentsreported nothing; it now runs the same check (ContentsOnNodeProblemis one constant shared by both paths) and is still walked./Contents-on-a-node message says what makes the entry anomalous (a Table 31 page-object key that §7.7.3.4 gives no inheritance path) alongside Table 30's silence, which on its own every optional key shares; the disjoint-CropBox message no longer claims §14.11.2.1 mandates the MediaBox substitution it only makes as its own fallback (the one test on that text assertsContains("CropBox")and needed no change); the stray-/Kids-on-a-page message cites Table 30's Kids row rather than a Type row that also admits Template.MaxDiagnosticsparagraph names the three walk-stop codes as the exemption from the cap, and thePdfReaderOptions.MaxDiagnosticsremarks say the same; the count of retained entries per walk is two everywhere it read three, and a test comment that still described the pre-round-3 behaviour is updated.Reader.Tests at
036355bwith oracles required: 1113 total, 0 failed, 12 skipped;PageTreeTestsandDiagnosticSinkTeststogether 93 total, 0 failed. Build, format and clean-room checks green at036355b; branch diff againstorigin/mainadds 0 em dashes and removes 7.PublicAPI.Unshipped.txtis untouched sinceb6f2785.Review fix-ups (round 5)
IsAbsentimplemented one. "An indirect object reference to a nonexistent object shall be treated the same as a null object" makes/Contents 9 0 Rwith 9 free, or a reference to an emittednullobject, the same value as/Contents null, butIsAbsenttested the raw reference, so the dangling shapes still reported (PageTreeNodeMalformedfor/Contentson a node,PageAttributeInvalidfor/MediaBoxor/Rotateon a leaf where silent inheritance is the correct outcome).ResolveOrAbsentresolves once and hands the result downstream, treating a resolvednullor a resolution to nothing as absent; a reference the parser cannot parse at all stays present, since that object exists. The catalog's own/Pagescheck gets the same treatment, so/Pages nullreports "no/Pagesentry" rather than "does not resolve to a dictionary". The three stray-/Contentsfixtures used exactly the dangling reference the rule makes absent; they now carry/Contents []. Four new tests cover a dangling/Contents, a dangling/MediaBox, a reference to an emittednullobject, and/Pages nullon the catalog./Contentstest), corrected above, together with "instead of citing Table 30's silence" (the message cites it alongside §7.7.3.4) and theFilters.csparallel (which also emits an Info diagnostic; the walker does not).PageTreeNodeMalformed's doc called its subject a page-tree node and then said it did not classify as one, and listed the no-usable-/Kidscase for the root, wherePageTreeMissingfires instead; reworded.PageAttributeInvalid's nested either/or is a list now.### Changedentry for the round-2ParseRealchange (a non-finite real literal throwsInvalidDataExceptionwhere 2.3.0 leakedArgumentException), since it is reachable by any real-number resolution, not only the page walk. The guide's Pages section states the §7.3.9 rule.ResolveRotateAttribute's doc no longer says "raw null" for what is now absent-or-null-or-dangling; the untyped-node bullet no longer names a/Kids-required branch thatClassifyByTypemakes unreachable; the stray-/Kidsmessage keeps the Table 31 half of its citation and drops the Table 30 Kids-row half, which constrains children, not which dictionaries may carry the key; the walker's remarks lose a sentence that restated the two before it; the guide no longer says the root's/Contentscheck runs "before anything else", since a root that is not a node returnsPageTreeMissingfirst; one short hard wrap is reflowed.(PageTreeNodeMalformed, null, null)dedupe key with other object-number-less nodes; there is still no sink test for the Report-then-ReportRetained order.The round-5 adversarial lens measured the delta at
036355b: depth limit then leaf cap in one walk at a cap of 2 gives 5 entries (2 ordinary, 1 sentinel, 2 retained), never a second retained depth report (40 over-depth siblings at a cap of 1 give 3 entries); ancestor/MediaBox nullskipped with the real value above it still found;ParserFuzzTestsat 60 000 iterations and a 30 000-iteration null-injection fuzz over/Type /Kids /Contents /MediaBox /CropBox /Rotate /Resources /Parentand the catalog's/Pagesgave 0 exceptions outside the documented set; the five round-4 tests run in about 1 ms each.Reader.Tests at
2e52577with oracles required: 1117 total, 0 failed, 12 skipped;PageTreeTestsandDiagnosticSinkTeststogether 97 total, 0 failed. Build, format and clean-room checks green at2e52577; branch diff againstorigin/mainadds 0 em dashes and removes 7;PublicAPI.Unshipped.txtunchanged.Review fix-ups (round 6)
ResolveOrAbsentfollowed one hop. §7.3.10's 2020 NOTE permits chains of indirect references ("any object outside of an object stream can consist solely of an object reference"), with semantics equivalent to a direct value, so/Pages 2 0 Rwhere object 2 is3 0 Rand object 3 is the root gavePageCount0 withPageTreeMissing, a node/Contentschain ending innullgave a falsePageTreeNodeMalformed, and a leaf/MediaBoxchain to a real rectangle was dropped.TryResolvenow follows a chain at every resolve site in the walker (/Pages,/Kidselements,/Type,/Contents, each attribute and each rectangle element) up toMaxReferenceChainHops(32, this reader's own bound), with a per-chain visited set: a cycle, including a self-reference, or an over-cap chain is present but unusable, the outcome an unparseable target already had, so the existing "did not resolve to" diagnostics fire and no new code is added.PdfDocumentReader.ResolveValueitself is untouched; Conformance consumes it and is Shipped. Tests:/Pagesreference to a reference, chained/Contentsand/MediaBox, a mutual cycle, the hop cap.036355bthrough/MediaBox: the reader's resolve cache remembers successful parses only, so N nodes whose/Contents(or/MediaBox) named one large object that fails to parse re-parsed it once per node. Measured at2e52577: 500 nodes 3.7 s, 1000 7.1 s, 2000 14.7 s, 4000 30.0 s, linear in N, withMaxKidsExaminedputting the ceiling near two hours. A per-walkFailedResolveCachekeyed on (object number, generation) remembers which targets resolved to nothing and which threw or cycled, so a shared failing object costs one parse per walk; the two kinds are kept apart because a clean null is absent (silent inheritance) while a throw or a cycle stays present (the diagnostic keeps firing). Same shape at N=2000: 16.0 s before, 0.05 s after. The reader-level diagnostics for those objects are already deduped by the sink, so the skipped repeat calls lose nothing. Tests build 900 nodes sharing one failing target through/Contentsand through/MediaBoxand pin page count and diagnostics, with no wall-clock assertion (Tests: wall-clock assertions flake on the shared CI runner #400).PageTreeMissingnever reaches its stray-/Contentscheck; a/Type /Pagesroot with no usable/Kidsdoes, and reports both (ContentsOnNodeProblemfirst,PageTreeMissingafter), so the sentence now says so. The CHANGELOG andPdfObjectParser.cssaid 300 or more integer digits overflowdouble.TryParse; measured on .NET 10, 300 to 308 parse finite, 309 depends on the value, and 310 or more always overflow (the branch's own test uses 301 digits and asserts finite), so both say 310 and cite Annex C.2 / Table C.1 rather than C.1; the CHANGELOG entry also named the public boundary, wrongly as it turned out (see round 7).ResolveOrAbsent's doc claimedFilters.cshandled only the directPdfNullcase;GetFilterListdereferences first and treats a dangling or null-object reference as absent too, so the doc now states the actual precedent, thatFilters.csdoes not catchInvalidDataExceptionand lets an unparseable target propagate, which is why the walker treats one as present./Typewas read raw, so/Type 9 0 Rwith9 0 obj /Pagewas walked by structure; it resolves through the same path now (§7.3.7 requires only keys to be direct). The catalog/Pages 9 0 Rwith 9 free reported "no /Pages entry" with no object number; it now says the entry (object 9) resolves to no object or to the null object, withObjectNumber9, and the generation-mismatch shape gets the same. TheResolveOrAbsentdoc records that a generation or header mismatch resolves to null without throwing and lands on the absent side (§7.3.10 identifies an object by number and generation together), and that a missing object-stream member throws insideResolveFromObjectStreamand so counts as present, a reader-wide gap left for a follow-up. The guide's §7.3.9 sentence is scoped to dictionary entries;/Kidselementsnullor dangling still reportPageTreeKidNotDictionaryper Table 30's Kids row.IsAbsenthad one caller and a doc describing others; inlined. The three re-pointed fixtures used/Contents [], which Table 31 forbids writers to produce, under a comment calling it a real target; they reference a real stream now.PageTreeNodeMalformed's doc and the guide described the root's classification in words that read as contradicting each other (ClassifyByTypeis shared with the root,ClassifyNodeis not); both say the same thing now. The rectangle failure message distinguishes a target that failed to parse from one of the wrong shape.2e52577("round-3ParseRealfix", it was the round-2 fix-up6a03b6f) and036355b("instead of Table 30's silence") are history; the squash body is what lands onmain. A direct-dictionary/Pages(Table 29: shall be an indirect reference) is still accepted silently, as recorded in round 5.b02b1bfis style only: the four--dash substitutes the fix-up introduced, and the chained/Pagesmessage no longer says the first object in the chain "does not exist".The round-6 adversarial lens also measured the round-5 delta itself as clean: 1 000 000 direct nodes walk in 1.08 s at both
036355band2e52577, 1 000 000 direct leaves 2.5 s, a 5 200-deep chain 6 ms; 90 000 injection-fuzz documents over every walker key gave 0 exceptions out ofPageCount,PagesandGetPageat both revisions with matching page counts; the sink accounting at caps 1, 2 and 5 gave cap + 2 retained + 1 sentinel.Reader.Tests at
b02b1bfwith oracles required: 1127 total, 0 failed, 12 skipped;PageTreeTestsandDiagnosticSinkTeststogether 107 total, 0 failed; Conformance.Tests 1259 total, 0 failed, 0 skipped. Build, format and clean-room checks green; branch diff againstorigin/mainadds 0 em dashes and 0--substitutes and removes 6 em dashes;PublicAPI.Unshipped.txtunchanged.Review fix-ups (round 7)
/Kidselement whileTryResolvefollowed chains, so/Kids [5 0 R 6 0 R]with5 0 obj 4 0 Rand6 0 obj 4 0 RgavePageCount2,ObjectNumber5 and 6, both pages sharing oneDictionaryinstance, no diagnostic; §7.7.3.2 and §7.7.3.3 forbid multiple references to one node or page, and at2e52577that file was rejected withPageTreeKidNotDictionary.TryResolveandResolveOrAbsentnow also report the object number of the last reference followed, and the walker keys the repeat guard, the/Kids-array guard, thePageTreeCyclereport andPdfReadPage.ObjectNumberon that terminal object. The/Pages-to-free message names the object that failed to resolve rather than the first hop. Six tests: two aliases of one page, direct plus alias, a/Kidsarray through two aliases,ObjectNumberthrough an alias, chained/Pagesto a free object, and a 20-level aliased-node fan-out that terminates with onePageTreeCycleper level.PageCount,PagesandGetPagethrowInvalidDataExceptionfor an out-of-range real literal where 2.3.0 threwArgumentException. The walker catches that exception per object (the branch's ownRealOutOfRange_*_noThrowtests assert it), and 2.3.0 had none of those members. The entry now saysPdfReader.OpenthrowsInvalidDataExceptionwhere 2.3.0 threwArgumentException(2.3.0'sParseRealhad no non-finite guard) and that the page-tree walk, new in this release, reports a diagnostic instead; a test pins theOpenhalf with the literal inline in the catalog. The round-6 bullet above is corrected to match.(8, 0)to(8, 1)was a false cycle that suppressed the reader'sObjectGenerationMismatchand cached(8, 1)as unusable, making a later unrelated8 1 Ranswer by walk order; keyed on the pair now, with a theory test over both/Kidsorders. TheTryResolvedoc quoted §7.3.10's NOTE and a separate normative paragraph of §7.3.10 as one passage; they are cited as two. TheFailedResolveCachedoc claimedMaxKidsExaminedbounds its size; each examined kid drives up to about fifteen targets (seven keys plus the eight rectangle elements) plus up to 32 hops each, so the bound is a small multiple of it (measured about 29 bytes per entry, about 61 MB at one million entries); the same fix-up also claimed the xref's object count as a tighter bound, which round 8 disproved (see below). The scale test's comment called the pre-fix cost quadratic; one full re-parse per node is linear in N with a large constant, which the round-6 numbers (3.7, 7.1, 14.7, 30.0 s at 500, 1000, 2000, 4000 nodes) show.65e261cbody says "quadratic" and uses a spaced hyphen as a dash, and theb02b1bfsubject is exactly 72 characters; both are branch history that the squash body replaces. The two pre-existing wall-clock assertions inPageTreeTests(< 2 sat a 0.000 s test,< 5 sat 0.028 s) predate round 6 and stay. Round 6 gave two N=2000 figures for the shared-failing-target shape (14.7 s and 16.0 s); they came from two different harnesses (the round-6 reviewer's and the fix-up's own) at2e52577, so the round-7 adversarial lens re-measured 16.9 s (/Contents) and 17.3 s (/MediaBox) at2e52577against 71 ms and 82 ms atb02b1bf, linear to N=8000 (162 ms).The round-7 adversarial lens found no MEDIUM in the round-6 delta: one million kid elements each naming a distinct dangling target walk in 4.2 s; a 200 000-element valid object in the middle of a chain shared by 2000 nodes is parsed once per walk (0.21 s); six over-cap chains on every one of 200 000 nodes cost 2.25 s against 1.39 s at
2e52577; a healthy 100 000-page document shows no regression (0.66 s against 0.82 s);MaxResolveDepthis never accumulated by the iterative chain loop;ParserFuzzTestsat 60 000 iterations and 30 000 injection-fuzz documents (chains, cycles, mixed generations, huge reals) let nothing escapePageCount,PagesorGetPage.Reader.Tests at
8de3b2fwith oracles required: 1138 total, 0 failed, 12 skipped;PageTreeTestsandDiagnosticSinkTeststogether 118 total, 0 failed. Build, format and clean-room checks green; branch diff againstorigin/mainadds 0 em dashes and 0--substitutes and removes 6 em dashes;PublicAPI.Unshipped.txtunchanged.Review fix-ups (round 8)
Round 8 ran the three lenses over
b02b1bf..8de3b2f. No behavioural finding: the adversarial lens measured 24 shapes head against base (one million repeats of one reference 639 ms; one million distinct aliases of one page 1.3 s, one page, one cycle report, where base returned 100 000 pages; the 32-reference chain succeeds and the 33rd is inspected but never resolved, at every walker key; the generation-keyed cycle set gives identical diagnostics in both/Kidsorders; retained memory flat inMaxDiagnostics;ParserFuzzTestsat 60 000 iterations plus 120 000 injection documents and 130 000 invariant documents with no exception outside the documented set), and the conformance lens confirmed terminal identity in seventeen alias, free-object, cycle and direct-dictionary cases against §7.3.9, §7.3.10, §7.7.3.2 and §7.7.3.3. Three MEDIUMs, all sentences that measurement contradicted, fixed in8dd3670:FailedResolveCachedoc claimed its population was bounded by the xref table's object count.TryResolveadds a key whenever a resolve returns null, which is exactly a reference the xref cannot name; 300 000 distinct nonexistent targets against a 4-object xref grew the cache to 300 000 entries (180 MB allocated against 35 MB when all name one missing object). The doc now states the bound that holds: distinct (object number, generation) pairs the walk resolves, at most one perTryResolvecall.PageTreeNodeMalformed(reported when the literal is the body of the object a non-root node's/Kidsor/Contentsnames), and called the walk "the one caller" that catches the exception while the walker's own comment names object-stream member resolution andSaveDecryptedas two others. Both corrected.2e52577. The numbers are gone; the comment describes the mechanism.PageCount2 and the alias's object number, which is what the test asserts), rewritten; a section banner duplicated on one 462-character line and an orphan comment line, both from8de3b2f, restored; the §7.3.10 NOTE quotation had elided "(see 7.5.7, "Object streams")" unmarked and theTryResolvedoc had dropped "Except where documented to the contrary,", both quoted in full; two intensifiers; the reader guide's Pages section now says that a repeat reached through different aliases is onePageTreeCyclenaming the target and one page under its own object number. In this body, the Verification bullet's Reader totals are pinned toeb36429and the round-7 "about eleven targets" gloss is corrected to about fifteen./Rotateonly, the other keys were probed by the reviewers; page-tree diagnostics carryGeneration = null; thePageTreeKidNotDictionarypath no longer adds tovisited, so a repeated reference to a non-dictionary re-resolves each time (a reader-cache hit) and no longer draws a spurious cycle report.Gates at
8dd3670: build 0 warnings, format, clean-room,PageTreeTestsandDiagnosticSinkTests118 total, 0 failed;VellumPdf.Reader.Testswith all oracles required 1138 total, 0 failed, 12 skipped;ReaderDocsConsistencyTests1/0; the branch diff againstc6ea95fadds 0 em dashes and 0--substitutes;PublicAPI.*.txtunchanged sinceb6f2785.Review fix-ups (round 9)
Round 9 ran three lenses over
8de3b2f..8dd3670: conformance GREEN (every rewritten sentence checked against the ISO text or a fixture: both §7.3.10 quotations byte-identical to the spec, the CHANGELOG's four-code list held against 20 placements of an overflowing literal, the guide's alias sentence held for pages, nodes, three aliases and unequal hop lengths, the fan-out comment's four claims reproduced on a build ofb02b1bf), adversarial GREEN (8dd3670is comment-only: 0 non-comment lines differ and the Release Reader DLL's 495 methods have identical IL; 84 fresh shapes head and base including AES-256 twins, 240 000 injection-fuzz documents and 800 000ParserFuzzTestssamples with 0 escapes; direct-dictionary/Pagesrecursion hits the parser's 256-level nesting limit beforeMaxDepthand is caught by the walk from 128 levels (PageTreeMissing, 127 still yields the page), unless the recursion is inline in the catalog, whereOpenthrowsInvalidDataExceptionfrom 127 levels; sink retained entries never exceed two and the suppression sentinel is one entry), API/prose NOT GREEN on one MEDIUM. Fix-up4849e9c(docs and comments only):TryResolve's doc citedSaveDecryptedand object-stream member resolution as precedent for recovering fromInvalidDataExceptionper object. Neither does: everySaveDecryptedcatch rethrows throughWrapResolveFailure, andResolveFromObjectStreamhas no catch, which the walker's ownResolveOrAbsentdoc already says. The sentence now cites the two sites that do recover, reconstruction'sTryParseObjectStreamMemberDirect(returns null) and theXrefReconstructorscan (charges the budget and resumes past a failed candidate). The CHANGELOG edit from round 8 that dropped "the one caller" stands on its own:XrefReconstructor.csandPdfObjectParser.cscatch and continue.FailedResolveCachedoc said its population was every pair the walk resolves;Addhas three call sites, all failure paths, so an all-successful walk holds 0 entries and the 900-node negative-cache fixture holds 2. It now says every pair the walk fails to resolve.PageTreeCycle's enum doc gained the terminal-identity nuance the guide already had.Pages; a shared node's descendant pages are listed once each); ragged wrap lines from8dd3670reflowed. In this body, the "What" bullet on/Rotateno longer says a non-multiple of 90 gives 0 (it falls back to the nearest valid ancestor first, round 1). The PR title is shortened so the squash subject with its(#398)suffix stays under 72 characters.8dd3670's commit body carries the same false precedent claim, saysTryResolve"adds a key on any resolve", and gives the fan-out at base as 23 ms where the round-8 section above says 108 ms (round 9 measured 69 to 73 ms; no test asserts on it);6a03b6f(93) andeb36429(75) also have subjects over 72 characters andeb36429's body has three em dashes. All of that is branch history the squash body replaces. The two wall-clock assertions inPageTreeTests(< 2 sat 0.000 s,< 5 sat 0.035 s) stay, as recorded in round 7; Tests: wall-clock assertions flake on the shared CI runner #400's sweep covers them. A/Typenaming an object that throws is classified structurally with no diagnostic, which ISO does not require reporting; noted for the follow-up issue.8dd3670's claim of 300 000 entries against a 4-object xref reproduces at about 510 bytes per distinct dangling target (181 MB allocated distinct against 35 MB shared), and 1 000 001 distinct targets at theMaxKidsExaminedcap peak at 192 MB working set in 492 ms.Review fix-ups (round 10)
Round 10 ran three lenses over
8dd3670..4849e9c: conformance GREEN (every rewritten sentence checked against the cited code, an instrumentedFailedResolveCachebuild or the ISO text; the two aliases of one node give the same page list as the single-alias control on count, index, object number,MediaBox,CropBoxandRotate, plus onePageTreeCycle), adversarial GREEN (4849e9cis comment-only: 0 non-comment lines, 495 methods with identical IL and an identical 407-entry user-string heap against8dd3670;git merge-treeagainstorigin/mainis conflict-free and the merged tree builds and passesVellumPdf.Reader.Tests1138/0/12; full solution 5977 total, 0 failed, 12 skipped with every oracle required; AOT smoke passed; the CI coverage script run locally gives 89.3 % combined with Reader at 94.57 % over 3410 instrumented lines against the 1900 floor; 30 000 injection-fuzz documents and 60 000ParserFuzzTestsiterations, 0 escapes), API/prose NOT GREEN on two MEDIUMs in this body rather than in the tree.## Whatbullet on/Kidsshape problems said a non-root intermediate contributes zero pages without a diagnostic. Round 1 made a/Type /Pagesnode without a usable/KidsreportPageTreeNodeMalformed, and an untyped dictionary whose/Kidsis not an array is classified as a leaf. The bullet now says so.## Whatbullet on normalisation saidPageIndexnames the affected page for an ancestor-sourced attribute failure. Round 3 made those reports carry a nullPageIndex, once per node; thePdfReaderDiagnostic.PageIndexdoc and the guide already said so. The bullet now matches them./Kidsarray object numbers; the CHANGELOG line names both### Addedand### Changed; the leaf-cap timing carries both measurements; the round-9 section's direct-dictionary recursion boundary is 128 levels for the walk and 127 for the catalog-inline variant (it gave 127 for both).5eed353: comments, one guide line and one diagnostic message string; no logic change, and no test asserts on that message text): thevisitedcomment's split subject left "and describe/Kidsas a tree" without a plural subject; thePageTreeCycleXML doc and the runtime message kept the "each forbid ... node or page object" wording the walker comment had just replaced; theFailedResolveCachesummary said the population is every pair the walk fails to resolve, while a chain given up on at the hop cap adds nothing (the threeAddcall sites are the cycle, throw and resolved-to-null exits); the guide's "as if the second alias had never been written" now carves out the diagnostic;TryResolve's precedent parenthetical now says theXrefReconstructorscan charges and resumes past a failed candidate object, not an xref stream.4849e9c..d1f4f40, this body and the squash body): NOT GREEN on one MEDIUM in this body, the bullet above having called5eed353"no code" while it rewrites thePageTreeCyclemessage string; corrected here. LOWs recorded: the bullet omitted theTryResolveparenthetical (added above);5eed353's commit body says threeXrefReconstructorcatch sites recover unconditionally, but all three charge the budget and throw on exhaustion, so the fourth differs only in checking exhaustion explicitly before continuing (history, not rewritten; the in-tree parenthetical is accurate). NITs: "both describe/Kidsas a tree" overreaches, since §7.7.3.3 speaks of the page tree and its leaves, not/Kids; the cycle message cites both clauses before classification, so a repeated object of neither kind gets the same citation; the contrastive uses of "actually" in the walker comments stay.origin/main(two commits sincec6ea95f, both touching onlyCHANGELOG.mdamong this branch's files) appends a second### Changedheading under[Unreleased]; the merge commitd1f4f40on this branch folds the two (gates atd1f4f40: build 0/0, format, clean-room,PageTreeTests+DiagnosticSinkTests+ReaderDocsConsistencyTests119/0,VellumPdf.Reader.Tests1138 total, 0 failed, 12 skipped with every oracle required; the branch adds 0 em dashes and 0 spaced double-hyphens againstorigin/main;PublicAPI.Unshipped.txt+18 unchanged).4849e9c's commit body says the two clauses apply to different objects "not jointly to both as the prior wording implied" while thePageTreeCycledoc it edited kept the joint wording until this round; history. The root README's package blurb is refreshed in the release commit, as for 2.2.0 and 2.3.0, not here.Verification
dotnet build,dotnet format --verify-no-changes, clean-room check: green ateb36429.VellumPdf.Reader.Teststhroughdotnet exec(dotnet testreports "zero tests ran" under Microsoft.Testing.Platform in this environment; CI is authoritative): 1058 total, 0 failed, 40 skipped (local oracle skips) ateb36429; the per-round sections below carry the totals at each later commit.VellumPdf.Conformance.Testswith veraPDF required: 1259 total, 0 failed, 2 skipped ateb36429.PublicAPI.Unshipped.txt+16 lines ateb36429, +18 atb6f2785,2b91eae,036355b,2e52577,b02b1bf,8de3b2f,8dd3670,4849e9c,5eed353and the merge commitd1f4f40(codes 206 and 207 added in round 1; rounds 3 to 10 add no public symbol), UTF-8 without BOM, LF.