Skip to content

feat(reader): walk the page tree and expose PdfReadPage - #398

Merged
Tim81 merged 15 commits into
mainfrom
feat/98-page-tree
Sep 3, 2026
Merged

feat(reader): walk the page tree and expose PdfReadPage#398
Tim81 merged 15 commits into
mainfrom
feat/98-page-tree

Conversation

@Tim81

@Tim81 Tim81 commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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 is PreflightContext.WalkPages in Conformance, which resolves inherited attributes by chasing /Parent; a forged /Parent redirects 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 /Kids arrays (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, /Rotate from the walker's own ancestor stack (§7.7.3.4); nearest ancestor wins, the page's own entry wins over all. Lazy (first access of PageCount/Pages/GetPage), cached for the reader's lifetime; never runs in the constructor.
  • Public: PdfDocumentReader.PageCount, Pages (IReadOnlyList<PdfReadPage>), GetPage(int) (ArgumentOutOfRangeException outside [0, PageCount)); PdfReadPage (sealed, no public constructor) with Index, ObjectNumber (int?, null for a direct /Kids dictionary), Dictionary, MediaBox, CropBox, Rotate. Resources is internal in 2.4 by maintainer decision. PdfRectangle is Kernel's existing type; no new geometry type.
  • Normalisation: MediaBox corners ordered; a missing or malformed /MediaBox falls back to Letter with PageAttributeInvalid; CropBox defaults to MediaBox when absent (§7.7.3.3); /Rotate folded 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) plus PageAttributeInvalid (round 1). The diagnostic's ObjectNumber names the node that supplied the malformed value. When that node is the page itself, PageIndex names the page; when it is an ancestor, PageIndex is null and the entry is reported once for the node rather than once per descendant page (round 3).
  • Diagnostic codes in the 2xx range: PageTreeMissing = 200 (Error: PageCount is 0, nothing partial survives, the same pattern UnknownFilter uses), PageTreeCycle = 201, PageTreeDepthExceeded = 202, PageTreeLeafLimitExceeded = 203, PageTreeKidNotDictionary = 204, PageAttributeInvalid = 205, PageTreeNodeMalformed = 206, PageTreeNodeLimitExceeded = 207 (all Warning). PdfReaderDiagnosticCodeTests extended for value range, severity and uniqueness.
  • A missing /Root, a missing or non-dictionary /Pages, or a root node whose /Kids is missing or not an array → PageCount == 0 plus PageTreeMissing. A non-root /Type /Pages node with no usable /Kids reports PageTreeNodeMalformed and contributes no children (round 1); an untyped dictionary whose /Kids is not an array has no structural tell left and is classified as a leaf, so it contributes one page.
  • Capability-table row "Page tree walk and page access" in src/VellumPdf.Reader/README.md and docs/reader-guide.md (byte-identical block), a guide subsection, CHANGELOG under ### Added plus one ### Changed entry for the ParseReal change (round 5).

Tests

PageTreeTests (52 cases at b6f2785; 23 at eb36429): writer-built 3-page document (indices, object numbers, MediaBox, both out-of-range GetPage calls); nested intermediates in document order; /Count lying low and high; inheritance from the root, from the nearest intermediate, and the page's own override; forged /Parent pointing at an unrelated dictionary; a kid array containing its own ancestor (terminates, pages before the cycle kept, PageTreeCycle names the object); a 300-deep chain (PageTreeDepthExceeded, pages under the cap kept); 100 001 leaves (PageTreeLeafLimitExceeded; 0.67 s at eb36429, 0.19 s after the round-3 rebuild with direct leaves); missing /Pages, non-dictionary /Pages, non-array /Kids, integer kid; reversed MediaBox corners, 3-element MediaBox, absent CropBox, /Rotate 450 / -90 / 45 / 90.0; laziness (no page-tree diagnostic until PageCount is read); the encrypted enc-aes-128-emptyuser.pdf fixture through the decrypting resolver. ParserFuzzTests now reads PageCount and iterates Pages per 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-/Parent and leaf-cap tests fail. Both restored.

Review fix-ups (round 1)

  • The HIGH finding: a node reached as a direct object (no object number) was never entered in the visited set, so a /Kids array 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 /Kids array object numbers reached by indirect reference in visited (a shared array is reported as PageTreeCycle on its second visit, 0.039 s on the fixture that took the round-1 walk past the review's patience), and a work budget MaxKidsExamined = 1_000_000 stops any walk built from distinct objects with PageTreeNodeLimitExceeded (207), measured at about 6 s on 1 000 000 distinct empty nodes with no cycle involved.
  • InvalidDataException from a malformed attribute (a 40-digit /Rotate, a /MediaBox element that is not a number) escaped GetPage; every attribute now goes through TryResolve per object, and a malformed own attribute falls back to the nearest valid ancestor value before the Letter convention, with PageAttributeInvalid.
  • Node/leaf classification is /Type-first (Table 30 and Table 31 both make /Type Required): /Type /Pages is a node, /Type /Page a leaf, a missing /Type is classified by the presence of /Kids, and a dictionary that is neither reports PageTreeNodeMalformed (206) and is skipped. A node with /Kids that is not an array reports the same code instead of PageTreeKidNotDictionary.
  • CropBox is intersected with MediaBox per §14.11.2.1 ("shall"); an empty intersection falls back to MediaBox with PageAttributeInvalid.
  • ObjectNumber doc: a page reached as a direct kid (permitted nowhere by Table 30, tolerated by the walker) reports ObjectNumber == 0 and no diagnostic (round 2 changed this to null); Dictionary is the live parsed dictionary, not a copy.
  • Identity KATs: every page's Dictionary is the same instance the resolver returns for its object number, and Pages[i].Index == i for every page.
  • Docs: the CHANGELOG bullet and the guide subsection list all eight 2xx codes, quote Table 30's "definitively determines the number of descendant pages" as the reason /Count is ignored, and give the classification rules, the inherited-value fallback order and the intersection; /Resources dropped 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 reads page.Dictionary, so a fuzz failure in the page walk is attributed to it rather than to the xref parse.
  • Em dashes added by the fix-up commit were swept back out in 093709a, measured against eb36429 only; round 2 measured the whole branch, see below.

Reader.Tests at 093709a: 1074 total, 0 failed, 40 skipped (local oracle skips); Conformance.Tests at f85a425 with 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 at 093709a.

Review fix-ups (round 2)

  • The HIGH finding: PdfObjectParser.ParseReal accepted a real literal of 310 or more integer digits, which double.TryParse turns into Infinity, and PdfReal's constructor then threw ArgumentException, a type PageTreeWalker.TryResolve does not catch; a /Rotate or /MediaBox element of that shape escaped PageCount, Pages and GetPage. ParseReal now rejects a non-finite result with InvalidDataException, which is what every other malformed token throws, and the page-walk block in ParserFuzzTests accepts only UnsupportedPdfFeatureException so the fuzz contract matches the documented one instead of the harness's broader catch.
  • Inherited-attribute resolution was O(nodes × depth): every leaf re-scanned its whole ancestor chain, about 14 s on a 1.8 MB file built to exercise it. Each walk frame now computes its effective /MediaBox, /CropBox, /Rotate and /Resources once when it is pushed, and a malformed ancestor attribute is reported once against that node rather than once per descendant page.
  • The root was walked without classification, so a root that is really a leaf or names an unrecognised /Type was treated as a node with zero children and no diagnostic. It now goes through the same /Type-first classification as any node reached through /Kids and reports PageTreeMissing. A present but empty /Kids, root included, still yields zero pages and no diagnostic: §7.7.3 allows a zero-page document.
  • IntersectWithMediaBox used <=, so a zero-width or zero-height CropBox that touched MediaBox was replaced; the §7.9.5 NOTE keeps such a rectangle as written, and the comparison is now strict.
  • PdfReadPage.ObjectNumber is int? (null for a direct /Kids dictionary) instead of overloading 0, the shape PdfReaderDiagnostic.ObjectNumber already has; the two diagnostic messages that named "object 0" now say "a direct page-tree node" or "the page dictionary".
  • A /Kids element 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.
  • Prose: the earlier rounds measured em dashes only in each fix-up commit; the branch as a whole had added 35 over its base. b6f2785 is a style-only sweep (33 lines in eight files, no code change, the capability-table blocks stay byte-identical); the branch diff against origin/main now adds 0 and removes 5.

Reader.Tests at b6f2785 with oracles required: 1091 total, 0 failed, 12 skipped (the pre-existing ThirdParty reconstruction cases); PageTreeTests 52 total, 0 failed; Conformance.Tests 1259 total, 0 failed, 0 skipped; Cli.Tests 899 total, 0 failed. Build, format and clean-room checks green at b6f2785.

Review fix-ups (round 3)

  • The adversarial MEDIUM: once MaxDiagnostics was 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.ReportRetained records 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 _ordinaryCount field is for). The walker uses it for the leaf and node limits and for the first PageTreeDepthExceeded of a walk; later depth reports are ordinary. PdfDocumentReader.Pages walks 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). Six DiagnosticSinkTests pin it (three at a cap of 1, the others at 2 and 10), and three PageTreeTests open a document with MaxDiagnostics = 2 and enough dangling kids ahead of the limit that the cap is full before the walk stops.
  • The API/prose MEDIUM: PdfReaderDiagnostic.PageIndex and the guide said PageAttributeInvalid reports carry the page index; the ones raised against an ancestor node carry null, 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.
  • Attribute failures on one object are collected and reported once. Before, a leaf with a bad /MediaBox, /CropBox and /Rotate reported the first and lost the other two to the sink's (code, object, page) dedupe. ResolveRectangleAttribute, ResolveRotateAttribute and IntersectWithMediaBox append their text to a per-object list that BuildPage and ComputeEffectiveAttributes report 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-CropBox report used to carry no object number (IntersectWithMediaBox reported with null); it now names the object like the others.
  • /Contents alongside /Kids on a node is reported as PageTreeNodeMalformed (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 /Template as a /Kids child is skipped with PageTreeNodeMalformed: 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).
  • Two tests confirm the round-3 reading of the inline-literal shape: a non-finite real inside a leaf's own /Rotate loses that leaf as PageTreeKidNotDictionary; on the root it loses the tree as PageTreeMissing.
  • Citations: the root requirement now cites Table 29 (§7.7.2) for /Root/Pages and §7.7.3.2 for what a node is; the PageAttributeInvalid doc separates §7.9.5 (rectangles) from §7.7.3.3 (the /Rotate rule); 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 /Kids array; a /Rotate 360 case joins the normalisation theory.
  • Three API decisions from the round-3 list stay as they are, recorded for the Graduate VellumPdf.Reader from Preview to Stable #187 review: PdfReadPage.Dictionary is a mutable PdfDictionary (the same shape as the shipped Catalog, documented read-only), Rotate stays int, and there is no Generation on PdfReadPage.
  • The CI flake (Tests: wall-clock assertions flake on the shared CI runner #400): ManyDistinctEmptyNodes_stopsAtTheKidsExaminedBudget_withNoCycleInvolved took 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_andReportsTheLimit got 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 2b91eae with oracles required: 1108 total, 0 failed, 12 skipped; PageTreeTests and DiagnosticSinkTests together 88 total, 0 failed. Build, format and clean-room checks green at 2b91eae; branch diff against origin/main adds 0 em dashes and removes 6.

Review fix-ups (round 4)

  • The conformance MEDIUM: the Template justification claimed a tension between §12.7.7 and Table 31's Required /Parent that 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.
  • Explicit null values are absent (§7.3.9). PdfDictionary.Get returns the PdfNull singleton for a /Key null entry, and the walker's presence checks tested raw is null, so /Contents null on a node, /Kids null on a /Type /Page object and /MediaBox null or /Rotate null on a leaf each raised a spurious diagnostic, while /MediaBox null on an ancestor counted as "the chain has one" and silenced the leaf's own missing-MediaBox report. A shared IsAbsent helper (the PdfNull special case Filters.cs already 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.
  • The root node is checked for a stray /Contents like any other node. Walk's root path never went through ClassifyNode, so /Root/Pages with /Contents reported nothing; it now runs the same check (ContentsOnNodeProblem is one constant shared by both paths) and is still walked.
  • Message texts and citations: the /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 asserts Contains("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.
  • The guide's MaxDiagnostics paragraph names the three walk-stop codes as the exemption from the cap, and the PdfReaderOptions.MaxDiagnostics remarks 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.
  • Recorded and left alone: the leaf-cap test uses direct leaves only (the node-cap test covers the nested shape), and there is no sink test for the Report-then-ReportRetained order (the walker tests cover it end to end).

Reader.Tests at 036355b with oracles required: 1113 total, 0 failed, 12 skipped; PageTreeTests and DiagnosticSinkTests together 93 total, 0 failed. Build, format and clean-room checks green at 036355b; branch diff against origin/main adds 0 em dashes and removes 7. PublicAPI.Unshipped.txt is untouched since b6f2785.

Review fix-ups (round 5)

  • The conformance MEDIUM: §7.3.9 has two sentences and IsAbsent implemented one. "An indirect object reference to a nonexistent object shall be treated the same as a null object" makes /Contents 9 0 R with 9 free, or a reference to an emitted null object, the same value as /Contents null, but IsAbsent tested the raw reference, so the dangling shapes still reported (PageTreeNodeMalformed for /Contents on a node, PageAttributeInvalid for /MediaBox or /Rotate on a leaf where silent inheritance is the correct outcome). ResolveOrAbsent resolves once and hands the result downstream, treating a resolved null or a resolution to nothing as absent; a reference the parser cannot parse at all stays present, since that object exists. The catalog's own /Pages check gets the same treatment, so /Pages null reports "no /Pages entry" rather than "does not resolve to a dictionary". The three stray-/Contents fixtures 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 emitted null object, and /Pages null on the catalog.
  • The prose MEDIUM was in this body: the round-4 §7.3.9 bullet said five tests where four are the §7.3.9 ones (the fifth is the root /Contents test), corrected above, together with "instead of citing Table 30's silence" (the message cites it alongside §7.7.3.4) and the Filters.cs parallel (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-/Kids case for the root, where PageTreeMissing fires instead; reworded. PageAttributeInvalid's nested either/or is a list now.
  • CHANGELOG gains a ### Changed entry for the round-2 ParseReal change (a non-finite real literal throws InvalidDataException where 2.3.0 leaked ArgumentException), 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.
  • Smaller: 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 that ClassifyByType makes unreachable; the stray-/Kids message 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 /Contents check runs "before anything else", since a root that is not a node returns PageTreeMissing first; one short hard wrap is reflowed.
  • Recorded and left alone: a direct-dictionary root (which Table 29 forbids) shares the (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 null skipped with the real value above it still found; ParserFuzzTests at 60 000 iterations and a 30 000-iteration null-injection fuzz over /Type /Kids /Contents /MediaBox /CropBox /Rotate /Resources /Parent and the catalog's /Pages gave 0 exceptions outside the documented set; the five round-4 tests run in about 1 ms each.

Reader.Tests at 2e52577 with oracles required: 1117 total, 0 failed, 12 skipped; PageTreeTests and DiagnosticSinkTests together 97 total, 0 failed. Build, format and clean-room checks green at 2e52577; branch diff against origin/main adds 0 em dashes and removes 7; PublicAPI.Unshipped.txt unchanged.

Review fix-ups (round 6)

  • The conformance MEDIUM: ResolveOrAbsent followed 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 R where object 2 is 3 0 R and object 3 is the root gave PageCount 0 with PageTreeMissing, a node /Contents chain ending in null gave a false PageTreeNodeMalformed, and a leaf /MediaBox chain to a real rectangle was dropped. TryResolve now follows a chain at every resolve site in the walker (/Pages, /Kids elements, /Type, /Contents, each attribute and each rectangle element) up to MaxReferenceChainHops (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.ResolveValue itself is untouched; Conformance consumes it and is Shipped. Tests: /Pages reference to a reference, chained /Contents and /MediaBox, a mutual cycle, the hop cap.
  • The adversarial MEDIUM, pre-existing at 036355b through /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 at 2e52577: 500 nodes 3.7 s, 1000 7.1 s, 2000 14.7 s, 4000 30.0 s, linear in N, with MaxKidsExamined putting the ceiling near two hours. A per-walk FailedResolveCache keyed 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 /Contents and through /MediaBox and pin page count and diagnostics, with no wall-clock assertion (Tests: wall-clock assertions flake on the shared CI runner #400).
  • The prose MEDIUMs: the guide said a root reported as PageTreeMissing never reaches its stray-/Contents check; a /Type /Pages root with no usable /Kids does, and reports both (ContentsOnNodeProblem first, PageTreeMissing after), so the sentence now says so. The CHANGELOG and PdfObjectParser.cs said 300 or more integer digits overflow double.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 claimed Filters.cs handled only the direct PdfNull case; GetFilterList dereferences first and treats a dangling or null-object reference as absent too, so the doc now states the actual precedent, that Filters.cs does not catch InvalidDataException and lets an unparseable target propagate, which is why the walker treats one as present.
  • LOWs: /Type was read raw, so /Type 9 0 R with 9 0 obj /Page was walked by structure; it resolves through the same path now (§7.3.7 requires only keys to be direct). The catalog /Pages 9 0 R with 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, with ObjectNumber 9, and the generation-mismatch shape gets the same. The ResolveOrAbsent doc 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 inside ResolveFromObjectStream and 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; /Kids elements null or dangling still report PageTreeKidNotDictionary per Table 30's Kids row.
  • NITs: IsAbsent had 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 (ClassifyByType is shared with the root, ClassifyNode is not); both say the same thing now. The rectangle failure message distinguishes a target that failed to parse from one of the wrong shape.
  • Recorded, not changed: the branch commit bodies of 2e52577 ("round-3 ParseReal fix", it was the round-2 fix-up 6a03b6f) and 036355b ("instead of Table 30's silence") are history; the squash body is what lands on main. A direct-dictionary /Pages (Table 29: shall be an indirect reference) is still accepted silently, as recorded in round 5.
  • b02b1bf is style only: the four -- dash substitutes the fix-up introduced, and the chained /Pages message 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 036355b and 2e52577, 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 of PageCount, Pages and GetPage at both revisions with matching page counts; the sink accounting at caps 1, 2 and 5 gave cap + 2 retained + 1 sentinel.

Reader.Tests at b02b1bf with oracles required: 1127 total, 0 failed, 12 skipped; PageTreeTests and DiagnosticSinkTests together 107 total, 0 failed; Conformance.Tests 1259 total, 0 failed, 0 skipped. Build, format and clean-room checks green; branch diff against origin/main adds 0 em dashes and 0 -- substitutes and removes 6 em dashes; PublicAPI.Unshipped.txt unchanged.

Review fix-ups (round 7)

  • The conformance MEDIUM, a regression of round 6: the walker took each kid's identity from the raw /Kids element while TryResolve followed chains, so /Kids [5 0 R 6 0 R] with 5 0 obj 4 0 R and 6 0 obj 4 0 R gave PageCount 2, ObjectNumber 5 and 6, both pages sharing one Dictionary instance, no diagnostic; §7.7.3.2 and §7.7.3.3 forbid multiple references to one node or page, and at 2e52577 that file was rejected with PageTreeKidNotDictionary. TryResolve and ResolveOrAbsent now also report the object number of the last reference followed, and the walker keys the repeat guard, the /Kids-array guard, the PageTreeCycle report and PdfReadPage.ObjectNumber on 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 /Kids array through two aliases, ObjectNumber through an alias, chained /Pages to a free object, and a 20-level aliased-node fan-out that terminates with one PageTreeCycle per level.
  • The prose MEDIUM (both lenses): the CHANGELOG sentence added in round 6 said PageCount, Pages and GetPage throw InvalidDataException for an out-of-range real literal where 2.3.0 threw ArgumentException. The walker catches that exception per object (the branch's own RealOutOfRange_*_noThrow tests assert it), and 2.3.0 had none of those members. The entry now says PdfReader.Open throws InvalidDataException where 2.3.0 threw ArgumentException (2.3.0's ParseReal had no non-finite guard) and that the page-tree walk, new in this release, reports a diagnostic instead; a test pins the Open half with the literal inline in the catalog. The round-6 bullet above is corrected to match.
  • LOWs: the hop cap inspected the value before each resolve and never after the last one, so a chain of exactly 32 references failed; the 32nd result is now inspected (tests at 32 resolves and at 33). The per-chain cycle set was keyed on object number alone while the cache key carried the generation, so a hop from (8, 0) to (8, 1) was a false cycle that suppressed the reader's ObjectGenerationMismatch and cached (8, 1) as unusable, making a later unrelated 8 1 R answer by walk order; keyed on the pair now, with a theory test over both /Kids orders. The TryResolve doc quoted §7.3.10's NOTE and a separate normative paragraph of §7.3.10 as one passage; they are cited as two. The FailedResolveCache doc claimed MaxKidsExamined bounds 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.
  • Recorded, not changed: the 65e261c body says "quadratic" and uses a spaced hyphen as a dash, and the b02b1bf subject is exactly 72 characters; both are branch history that the squash body replaces. The two pre-existing wall-clock assertions in PageTreeTests (< 2 s at a 0.000 s test, < 5 s at 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) at 2e52577, so the round-7 adversarial lens re-measured 16.9 s (/Contents) and 17.3 s (/MediaBox) at 2e52577 against 71 ms and 82 ms at b02b1bf, 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); MaxResolveDepth is never accumulated by the iterative chain loop; ParserFuzzTests at 60 000 iterations and 30 000 injection-fuzz documents (chains, cycles, mixed generations, huge reals) let nothing escape PageCount, Pages or GetPage.

Reader.Tests at 8de3b2f with oracles required: 1138 total, 0 failed, 12 skipped; PageTreeTests and DiagnosticSinkTests together 118 total, 0 failed. Build, format and clean-room checks green; branch diff against origin/main adds 0 em dashes and 0 -- substitutes and removes 6 em dashes; PublicAPI.Unshipped.txt unchanged.

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 /Kids orders; retained memory flat in MaxDiagnostics; ParserFuzzTests at 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 in 8dd3670:

  • The FailedResolveCache doc claimed its population was bounded by the xref table's object count. TryResolve adds 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 per TryResolve call.
  • The CHANGELOG's list of diagnostics an overflowing real can produce during the walk omitted PageTreeNodeMalformed (reported when the literal is the body of the object a non-root node's /Kids or /Contents names), and called the walk "the one caller" that catches the exception while the walker's own comment names object-stream member resolution and SaveDecrypted as two others. Both corrected.
  • The negative-cache test's comment quoted 3.7/7.1/14.7/30.0 s at 500 to 4000 nodes. Those came from the round-6 and round-7 harnesses' 1.8 MB failing target; the test's own 330-byte target costs 16/26/48/54 ms at those N on 2e52577. The numbers are gone; the comment describes the mechanism.
  • LOWs: the 20-level alias fan-out test's comment claimed an exponential walk that base does not exhibit (base terminated in 108 ms with 38 cycle reports; its defect was PageCount 2 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 from 8de3b2f, restored; the §7.3.10 NOTE quotation had elided "(see 7.5.7, "Object streams")" unmarked and the TryResolve doc 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 one PageTreeCycle naming the target and one page under its own object number. In this body, the Verification bullet's Reader totals are pinned to eb36429 and the round-7 "about eleven targets" gloss is corrected to about fifteen.
  • Recorded, not changed: the 32/33 hop boundary is pinned in tests on /Rotate only, the other keys were probed by the reviewers; page-tree diagnostics carry Generation = null; the PageTreeKidNotDictionary path no longer adds to visited, 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, PageTreeTests and DiagnosticSinkTests 118 total, 0 failed; VellumPdf.Reader.Tests with all oracles required 1138 total, 0 failed, 12 skipped; ReaderDocsConsistencyTests 1/0; the branch diff against c6ea95f adds 0 em dashes and 0 -- substitutes; PublicAPI.*.txt unchanged since b6f2785.

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 of b02b1bf), adversarial GREEN (8dd3670 is 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 000 ParserFuzzTests samples with 0 escapes; direct-dictionary /Pages recursion hits the parser's 256-level nesting limit before MaxDepth and is caught by the walk from 128 levels (PageTreeMissing, 127 still yields the page), unless the recursion is inline in the catalog, where Open throws InvalidDataException from 127 levels; sink retained entries never exceed two and the suppression sentinel is one entry), API/prose NOT GREEN on one MEDIUM. Fix-up 4849e9c (docs and comments only):

  • The MEDIUM: TryResolve's doc cited SaveDecrypted and object-stream member resolution as precedent for recovering from InvalidDataException per object. Neither does: every SaveDecrypted catch rethrows through WrapResolveFailure, and ResolveFromObjectStream has no catch, which the walker's own ResolveOrAbsent doc already says. The sentence now cites the two sites that do recover, reconstruction's TryParseObjectStreamMemberDirect (returns null) and the XrefReconstructor scan (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.cs and PdfObjectParser.cs catch and continue.
  • LOWs from two lenses: the FailedResolveCache doc said its population was every pair the walk resolves; Add has 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.
  • NITs: the visited-set comment attributes §7.7.3.2 to page tree nodes and §7.7.3.3 to page objects instead of "each forbid"; the guide no longer says a shared node is "listed" (only pages appear in Pages; a shared node's descendant pages are listed once each); ragged wrap lines from 8dd3670 reflowed. In this body, the "What" bullet on /Rotate no 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.
  • Recorded, not changed: 8dd3670's commit body carries the same false precedent claim, says TryResolve "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) and eb36429 (75) also have subjects over 72 characters and eb36429's body has three em dashes. All of that is branch history the squash body replaces. The two wall-clock assertions in PageTreeTests (< 2 s at 0.000 s, < 5 s at 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 /Type naming 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 the MaxKidsExamined cap 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 instrumented FailedResolveCache build 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, CropBox and Rotate, plus one PageTreeCycle), adversarial GREEN (4849e9c is comment-only: 0 non-comment lines, 495 methods with identical IL and an identical 407-entry user-string heap against 8dd3670; git merge-tree against origin/main is conflict-free and the merged tree builds and passes VellumPdf.Reader.Tests 1138/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 000 ParserFuzzTests iterations, 0 escapes), API/prose NOT GREEN on two MEDIUMs in this body rather than in the tree.

  • MEDIUM, this body: the ## What bullet on /Kids shape problems said a non-root intermediate contributes zero pages without a diagnostic. Round 1 made a /Type /Pages node without a usable /Kids report PageTreeNodeMalformed, and an untyped dictionary whose /Kids is not an array is classified as a leaf. The bullet now says so.
  • MEDIUM, this body: the ## What bullet on normalisation said PageIndex names the affected page for an ancestor-sourced attribute failure. Round 3 made those reports carry a null PageIndex, once per node; the PdfReaderDiagnostic.PageIndex doc and the guide already said so. The bullet now matches them.
  • LOW, this body: the cycle guard also holds indirectly reached /Kids array object numbers; the CHANGELOG line names both ### Added and ### 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).
  • LOW, tree (5eed353: comments, one guide line and one diagnostic message string; no logic change, and no test asserts on that message text): the visited comment's split subject left "and describe /Kids as a tree" without a plural subject; the PageTreeCycle XML doc and the runtime message kept the "each forbid ... node or page object" wording the walker comment had just replaced; the FailedResolveCache summary said the population is every pair the walk fails to resolve, while a chain given up on at the hop cap adds nothing (the three Add call 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 the XrefReconstructor scan charges and resumes past a failed candidate object, not an xref stream.
  • Round 11 (single prose and conformance reviewer over 4849e9c..d1f4f40, this body and the squash body): NOT GREEN on one MEDIUM in this body, the bullet above having called 5eed353 "no code" while it rewrites the PageTreeCycle message string; corrected here. LOWs recorded: the bullet omitted the TryResolve parenthetical (added above); 5eed353's commit body says three XrefReconstructor catch 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 /Kids as 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.
  • Recorded, not changed: merging origin/main (two commits since c6ea95f, both touching only CHANGELOG.md among this branch's files) appends a second ### Changed heading under [Unreleased]; the merge commit d1f4f40 on this branch folds the two (gates at d1f4f40: build 0/0, format, clean-room, PageTreeTests + DiagnosticSinkTests + ReaderDocsConsistencyTests 119/0, VellumPdf.Reader.Tests 1138 total, 0 failed, 12 skipped with every oracle required; the branch adds 0 em dashes and 0 spaced double-hyphens against origin/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 the PageTreeCycle doc 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 at eb36429.
  • VellumPdf.Reader.Tests through dotnet exec (dotnet test reports "zero tests ran" under Microsoft.Testing.Platform in this environment; CI is authoritative): 1058 total, 0 failed, 40 skipped (local oracle skips) at eb36429; the per-round sections below carry the totals at each later commit. VellumPdf.Conformance.Tests with veraPDF required: 1259 total, 0 failed, 2 skipped at eb36429.
  • PublicAPI.Unshipped.txt +16 lines at eb36429, +18 at b6f2785, 2b91eae, 036355b, 2e52577, b02b1bf, 8de3b2f, 8dd3670, 4849e9c, 5eed353 and the merge commit d1f4f40 (codes 206 and 207 added in round 1; rounds 3 to 10 add no public symbol), UTF-8 without BOM, LF.

…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.
@Tim81 Tim81 added this to the v2.4 — PDF content extraction milestone Sep 2, 2026
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.
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.
@Tim81 Tim81 changed the title feat(reader): walk the page tree and expose pages with inherited attributes feat(reader): walk the page tree and expose PdfReadPage Sep 3, 2026
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.
@Tim81
Tim81 merged commit 56ce572 into main Sep 3, 2026
4 checks passed
@Tim81
Tim81 deleted the feat/98-page-tree branch September 3, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant