Skip to content

Commit 497695c

Browse files
ajbufortclaude
andcommitted
test(page-count): fixture for the /Count=0 rescue + tighten comments (yfedoseev#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>
1 parent 9390f2d commit 497695c

1 file changed

Lines changed: 61 additions & 6 deletions

File tree

‎src/document.rs‎

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4186,9 +4186,12 @@ impl PdfDocument {
41864186
// non-encrypted document is almost always a page tree they could not
41874187
// resolve - `/Pages` packed inside an object stream, or a deeply nested
41884188
// `/Pages` -> `/Pages` -> `/Page` tree - not a genuinely empty document.
4189-
// `get_page` still reaches every page by walking `/Pages` -> `/Kids`
4190-
// (`all_page_refs`), so agree with it. Gated on a primary result of 0,
4191-
// so every document the standard reader counts normally is unchanged.
4189+
// The /Count readers and `all_page_refs` (which walks `/Pages` -> `/Kids`
4190+
// via `collect_page_refs`) both MISS such a tree; `get_page` still reaches
4191+
// every page through its own per-page traversal / `collect_all_pages` bulk
4192+
// walk, so count by agreeing with what it can actually reach. Gated on a
4193+
// primary result of 0, so every document the standard reader counts
4194+
// normally is unchanged.
41924195
if matches!(primary, Ok(0)) && !self.is_encrypted() {
41934196
// The /Count readers - and `all_page_refs`, which walks
41944197
// `/Pages` -> `/Kids` via `collect_page_refs` - miss a page tree
@@ -4197,9 +4200,11 @@ impl PdfDocument {
41974200
// bulk walk, so count by probing it: the definitive agreement with
41984201
// the pages the rest of the API can actually reach. `get_page` never
41994202
// calls back into `page_count` (no recursion) and caches each page
4200-
// (repeat probes are cheap). Bounded by a sanity cap. Only runs when
4201-
// the primary count is 0, so normally-counted documents are
4202-
// byte-identical.
4203+
// (repeat probes are cheap). For an ObjStm-packed tree each `get_page`
4204+
// can fall back to a full object scan, so counting this way is
4205+
// O(n * objects) - bounded by the sanity cap, and only ever on an
4206+
// already-broken document. Only runs when the primary count is 0, so
4207+
// normally-counted documents are byte-identical.
42034208
let mut n = 0usize;
42044209
while n < 1_000_000 && self.get_page(n).is_ok() {
42054210
n += 1;
@@ -28462,6 +28467,56 @@ mod tests {
2846228467
assert_eq!(doc.page_count().unwrap(), 1);
2846328468
}
2846428469

28470+
#[test]
28471+
fn test_page_count_rescued_when_count_is_zero_but_pages_exist() {
28472+
// The motivating broken-/Count case (#909): a `/Pages` node whose `/Count`
28473+
// says 0 while its `/Kids` hold real pages. An ObjStm-packed `/Pages` tree
28474+
// that the standard reader cannot resolve reaches `page_count` the same way
28475+
// - `primary == Ok(0)`. Here the standard reader trusts the literal `/Count`
28476+
// and returns 0, but `get_page` still walks `/Pages` -> `/Kids` and reaches
28477+
// every page, so the rescue enumerates them.
28478+
//
28479+
// WITHOUT the rescue block this returns 0 (verified: reverting the
28480+
// document.rs hunk makes this assertion fail with `0 != 3`); WITH it, 3.
28481+
let mut pdf = b"%PDF-1.4\n".to_vec();
28482+
let off1 = pdf.len();
28483+
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
28484+
let off2 = pdf.len();
28485+
pdf.extend_from_slice(
28486+
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 0 >>\nendobj\n",
28487+
);
28488+
let mut offs = vec![off1, off2];
28489+
for n in 3..=5u32 {
28490+
offs.push(pdf.len());
28491+
pdf.extend_from_slice(
28492+
format!(
28493+
"{} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
28494+
n
28495+
)
28496+
.as_bytes(),
28497+
);
28498+
}
28499+
let xref_off = pdf.len();
28500+
pdf.extend_from_slice(b"xref\n0 6\n");
28501+
pdf.extend_from_slice(b"0000000000 65535 f \n");
28502+
for off in &offs {
28503+
pdf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
28504+
}
28505+
pdf.extend_from_slice(
28506+
format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", xref_off)
28507+
.as_bytes(),
28508+
);
28509+
let doc = PdfDocument::from_bytes(pdf).unwrap();
28510+
// The standard /Count reader really does report 0 here, so the count of 3
28511+
// comes entirely from the enumerator rescue (not from the primary path).
28512+
assert_eq!(
28513+
doc.get_page_count_standard().unwrap(),
28514+
0,
28515+
"fixture must drive the standard reader to 0"
28516+
);
28517+
assert_eq!(doc.page_count().unwrap(), 3, "rescue must enumerate the real pages");
28518+
}
28519+
2846528520
// ========================================================================
2846628521
// NEW COVERAGE TESTS — Batch 16: nested page trees and caching
2846728522
// ========================================================================

0 commit comments

Comments
 (0)