fix(page-count): recover pages when /Count-based counting returns 0#909
fix(page-count): recover pages when /Count-based counting returns 0#909ajbufort wants to merge 2 commits into
Conversation
A page tree packed inside an object stream (PDF 1.5+ routinely compresses the Catalog and /Pages nodes), or a deeply nested /Pages -> /Pages -> /Page tree, can defeat both the /Count reader and the manual scan: page_count() returned Ok(0) and the document extracted as if empty - even though get_page resolves every page fine through its own traversal / collect_all_pages walk. (all_page_refs's collect_page_refs missed the same trees, so it was not a usable fallback either.) When the /Count-based readers yield 0 on a non-encrypted document, count by probing get_page - the definitive agreement with the pages the rest of the API can actually reach. get_page never calls back into page_count (no recursion), caches each page (cheap repeats), and the loop is bounded. Gated on a primary count of 0, so every normally-counted document is byte-identical. Recovers documents that previously produced NOTHING (e.g. a 14-page paper with its /Pages root in an /ObjStm: page_count 0 -> 14). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
|
Thanks for this — the approach is sound and I appreciate that it's careful about blast radius. What's good. The trigger guard is tight: the rescue block only runs when Blocker — please add a test. A change to page discovery needs a regression test, and this PR ships none. Please add a crafted fixture that reproduces the motivating case — an ObjStm-packed Minor (non-blocking):
I ran a 419-corpus regression to confirm no change on well-formed PDFs (the guard should guarantee it) — will post the result. Once the test lands this should be good to go. |
|
Corpus regression result (419-PDF suite, main vs this PR): 0 differing files across text/markdown/html. That's the expected and desired outcome — the |
|
Hi @ajbufort — first, a real thank-you. Your PRs target genuine, well-diagnosed gaps (inline-image decode, tagged-table reading order, deterministic font subsets, catalog recovery, off-MediaBox culling, and more), and the code quality is high. Several are merged or queued. We want to keep merging your work — this note is about making that faster and more reliable, not slower. Where we are. You currently have 9 open PRs, each a behavioral change to the extraction core. Two consequences:
What we're now asking on every behavioral PR (this is being formalized in our contribution policy, #901 —
A concrete example: this PR (#909) is logically sound but ships no test and no corpus diff, so we can't lock in the behavior or rule out side-effects — a broken- And a pacing request: please keep only a couple of PRs in flight at once (or open them as drafts until they're self-verified per the above), rather than the whole batch. That keeps the CI queue clear and gets each of yours a fast, clean run instead of getting cancelled to make room. To be clear, this isn't a reflection on the work — it's the opposite. These are exactly the practices in #901, and adopting them now means your changes land with far fewer round-trips and much faster. Genuinely grateful for the contributions, and looking forward to merging more. |
…fedoseev#909) Adds the regression test the review asked for: a broken-/Count PDF (a /Pages node with /Count 0 but real /Kids) that the standard reader counts as 0 while get_page still reaches every page, so the enumerator rescue recovers the real count (3). Verified it goes RED (0 != 3) when the rescue hunk is reverted, green with it. Also addresses the two review notes: tighten the outer comment so it no longer implies get_page reaches pages via all_page_refs (the premise is that the /Count readers AND all_page_refs miss these trees, while get_page's own per-page traversal / collect_all_pages reaches them), and acknowledge the O(n*objects) count for an ObjStm-packed recovered tree (bounded, only on already-broken docs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
|
Thanks Yury — test added, and it lands the "0 → N" it was missing. It's a self-built broken- Both minor notes are in too:
And thank you for running the 419-corpus regression. Agreed that 0 diffs is the correct, desired result here: the primary |
|
And separately — thank you for laying out the contribution policy (#901) so clearly, and for the generous framing. It's the right bar, and it makes our side better too; we're glad to adopt it across the board. Going forward on every behavioral PR we'll:
On pacing: understood re: the ~215-check matrix and one-PR-at-a-time runners. We'll keep only a couple in flight and move the rest to draft until each is self-verified per the above, so your queue isn't saturated and the active ones get clean, fast runs. Genuinely appreciate the detailed reviews and the corpus runs you've been doing on our behalf — that's exactly the load we should be carrying, and we'll take it from here. |
…fedoseev#909) Adds the regression test the review asked for: a broken-/Count PDF (a /Pages node with /Count 0 but real /Kids) that the standard reader counts as 0 while get_page still reaches every page, so the enumerator rescue recovers the real count (3). Verified it goes RED (0 != 3) when the rescue hunk is reverted, green with it. Also addresses the two review notes: tighten the outer comment so it no longer implies get_page reaches pages via all_page_refs (the premise is that the /Count readers AND all_page_refs miss these trees, while get_page's own per-page traversal / collect_all_pages reaches them), and acknowledge the O(n*objects) count for an ObjStm-packed recovered tree (bounded, only on already-broken docs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
|
Re-reviewed — this is cleared. Thanks for the fixture and the comment/perf cleanup. Confirmed the problem is real (before approving): disabled just the rescue ( i.e. without the fix, No corpus regression: the rescue block is gated on Approving and adding to the merge queue (ahead of the governance batch). |
yfedoseev
left a comment
There was a problem hiding this comment.
Approved — bug reproduced (revert-checked test), no corpus regression, review clean. Queued ahead of governance.
Problem
When a document's page tree is packed inside an object stream (PDF 1.5+ routinely compresses the Catalog and
/Pagesnodes), or is a deeply nested/Pages -> /Pages -> /Pagetree, bothpage_count()paths fail:get_page_count_standard()cannot resolve/Pagesto a dictionary and returnsOk(0), and the manualget_page_count_by_scanning()fallback returns 0 on the same nodes. Because the result isOk(0)(not an error), callers treat the document as empty and extract nothing.get_page(i)meanwhile resolves every page fine — it uses its own per-page traversal /collect_all_pagesbulk walk. So the pages are fully reachable; only the count is wrong. (Noteall_page_refsis not a usable fallback here: it walks/Pages -> /Kidsviacollect_page_refs, which misses the same object-stream-packed tree.)Concretely: a 14-page paper with its
/Pagesroot in an/ObjStmreportspage_count() == 0and produces no output, thoughget_page(0..14)each return a valid page.Fix
When the
/Count-based readers yield 0 on a non-encrypted document, count by probingget_page— the definitive agreement with the pages the rest of the API can actually reach:get_pagenever calls back intopage_count(no recursion), caches each page (cheap repeat probes), and the loop is bounded. Gated on a primary count of 0, so every normally-counted document returns the exact same value as before — genuinely empty documents (whereget_page(0)errors) still report 0.Full gate on this branch off
main:cargo test --lib(5750 passed),cargo test --doc(141 passed),cargo clippy --libclean,cargo fmt --checkclean.