Phase 2 of NIGIG_PDF_FEATURE_PARITY_PLAN.md, the item it calls "the biggest parse-side gap". Design and merge criteria in REVIEWS/adr/0013-pdf-xref-streams.md. The parser could not open a PDF 1.5 file. Not render it wrong - not open it: PARSE FAILED: PDF error at byte 382: expected xref keyword XRefTable::parse_section required the literal bytes `xref` at the startxref offset. A PDF 1.5+ file has an indirect object there instead - the xref stream - so the parse aborted and the entire document was unreadable. Every feature built on top of the parser (encryption, signatures, forms, structure tree, transparency) was unreachable on any file produced in the last twenty years. ObjStm, XRefStm and /Type /XRef appeared nowhere in the crate. Implemented on the read side: - Xref streams: the packed binary table, /W field widths, /Index sparse subsections, and types 0/1/2. A zero-width /W column means "use the default" (type 1) - missing that rule yields a table of all-free entries and an apparently empty document rather than an error. - Object streams: type-2 entries resolve through /ObjStm, reading the header pairs and /First. The xref's index is used but verified against the object number it claims to be, because a wrong-but-in-range index would silently return a different object. - Hybrid files: a traditional table plus /XRefStm. Both are read, with the traditional table winning on conflict, which is the point of the layout. Bounds and refusals rather than silent degradation: /W widths are clamped and every field read is checked against the decoded buffer; a truncated table is flagged, not padded with free entries; an object claiming to live inside itself is refused; a /Type that is not /XRef is named in the error. Scope note: the writer is untouched. ADR 0003 keeps appending a traditional xref section, which remains correct - the appended trailer carries /Prev to the stream, so the chain stays readable by us and by conforming readers. Also verified against the rest of Phase 2: inline images, XObject Do, shading, and the full text state (Tc/Tw/TL/Tz/Ts/Td/TD/Tm/Tf) are already implemented and tested. Type 3 fonts and the streaming interpreter remain genuine gaps, but each degrades one feature rather than the whole file. 6 corpus fixtures, 9 acceptance tests asserting real page content rather than a successful parse, and a parse_xref_stream fuzz target because the table is attacker-controlled binary. Mutation-checked: restoring the old error fails 5 of the 9. TEST_TARGET=pdf 606 -> 615, TEST_TARGET=pdf-ui 651 -> 660. rustfmt and clippy -D warnings clean.
7.3 KiB
ADR 0013: xref streams and object streams — read PDF 1.5+ at all
- Status: Accepted
- Date: 2026-08-02
- Review item:
NIGIG_PDF_FEATURE_PARITY_PLAN.md§1 Phase 2, "Read-side xref modernisation … nigigparser.rsis only 67 LOC; this is the biggest parse-side gap" - Supersedes: nothing
- Related: ADR 0003 (incremental save appends a traditional xref; that stays true and is now explicitly reconciled with hybrid files)
Context
The parser cannot open a PDF 1.5 file. Not "renders it wrong" — cannot open it. Probing a minimal document that uses an xref stream and an object stream, which is the layout essentially every producer has emitted since 2005:
PARSE FAILED: PDF error at byte 382: expected xref keyword
XRefTable::parse_section requires the literal bytes xref at the
startxref offset. A PDF 1.5+ file has an indirect object there —
5 0 obj << /Type /XRef … >> stream — so the parse aborts immediately and
the whole document is unreadable.
Grepping the crate confirms the scale: ObjStm, XRefStm, /Type /XRef
and object_stream appear nowhere in any .rs file.
This is the correct next target because it is the only Phase 2 item that
makes whole real-world files unreadable. The remaining Phase 2 entries are
narrower than the plan implies — inline images, Do, shading and the full
text state (Tc/Tw/TL/Tz/Ts/Td/TD/Tm/Tf) are already implemented and
tested; Type 3 fonts and the streaming interpreter are genuine gaps but
degrade one feature each rather than the whole file.
Decision
Implement the three modern cross-reference mechanisms of PDF 32000-1 §7.5.8
on the read side, in pdf-cos.
Xref streams (/Type /XRef)
A stream whose decoded body is a packed binary table described by /W,
optionally sparse via /Index. Fields:
- type 0 — free object.
- type 1 — uncompressed: field 2 is a byte offset.
- type 2 — compressed: field 2 is the object stream number, field 3 the index within it.
/W entries may be zero-width, which means "use the default": type
defaults to 1, the others to 0. That rule is easy to miss and produces a
table of all-free entries if ignored, so it is implemented explicitly.
Object streams (/Type /ObjStm)
A stream containing N objects concatenated after a header of N pairs
objnum offset, with /First giving the byte offset of the payload.
Objects inside carry no obj/endobj keywords.
Two constraints from the spec are enforced rather than assumed:
- An object stream cannot contain another object stream, and cannot
contain the document's
/Encryptdictionary. A file claiming otherwise is malformed and is refused for that object rather than recursed into. - Objects in an object stream always have generation 0.
Hybrid-reference files (/XRefStm)
A traditional xref table whose trailer carries /XRefStm pointing at an
xref stream holding the entries a 1.4 reader cannot see. Both are read,
with the traditional table taking precedence for any object number in
both — that is what the hybrid layout is for: the old table is the
authoritative view for old readers, and the stream only adds.
Decryption ordering
An xref stream is parsed before the decryptor exists, and per PDF 32000-1 §7.5.8.2 it is never encrypted. Object streams are encrypted, and must be decrypted as a whole stream before their contained objects are extracted — the objects inside must not be decrypted again individually, which would corrupt them. This ADR keeps object-stream extraction on the plaintext bytes the existing decrypt-on-read path already produces.
Interaction with incremental save (ADR 0003)
ADR 0003 appends a traditional xref table. That remains correct: appending
a traditional section to a file whose previous revision used an xref stream
produces a hybrid-shaped chain that both this reader and any conforming
reader can follow, because our appended trailer carries /Prev pointing at
the stream. Writing xref streams is out of scope; nothing in this ADR
changes the writer.
Non-negotiable rules
- A malformed xref stream must not be silently treated as an empty table. An empty table looks like a valid document with no objects, which is the silent-degradation failure this codebase keeps removing.
- Recursion is bounded. An object stream referencing itself, or a
/Prevchain through xref streams, must terminate. /Wwidths are attacker-controlled; every field read is bounds checked against the decoded buffer.- The traditional table wins in a hybrid file, per spec, and never the other way round.
- No writer change. ADR 0003's append stays byte-for-byte as it is.
Merge criteria
- A PDF 1.5 file with an xref stream parses, and its pages are readable.
- Objects stored in an object stream resolve.
/Wwith a zero-width first column defaults the type to 1./Indexsparse subsections are honoured.- A hybrid file (
/XRefStm) reads, with the traditional table winning on conflict. /Prevchains that mix traditional and stream sections resolve.- A truncated, cyclic or over-wide xref stream is a typed error, never a silent empty table and never a hang.
- An object stream that claims to contain an object stream is refused.
- Corpus fixtures for each of the above, generated by the checked-in script.
- Existing traditional-xref documents still parse unchanged.
TEST_TARGET=pdf ./tools/test-rust-clean.shpasses, rustfmt and clippy-D warningsclean.
All criteria met. TEST_TARGET=pdf went from 606 to 615 and
TEST_TARGET=pdf-ui from 651 to 660.
Mutation-checked: reverting the one-line dispatch in parse_section back to
Err("expected xref keyword") fails 5 of the 9 acceptance tests.
One fixture was wrong on the first attempt and is worth recording, because
the failure looked like a parser bug: stream_default_width.pdf originally
stored its catalog and page tree as compressed objects while declaring
/W [0 4 2]. With a zero-width type column every entry defaults to type 1,
so a compressed object cannot be expressed at all — the fixture was
unrepresentable, not the parser broken. It now stores those objects
uncompressed, which is what a real /W [0 4 2] file does.
/Extends on an object stream is parsed but deliberately not followed: every
object we need is listed in the stream's own header, so following the parent
chain would add recursion for no gain.
Consequences
Positive. The parser can open files produced in the last twenty years. Every downstream feature already built — encryption, signatures, forms, structure tree — becomes reachable on modern documents, where before it was all unreachable behind a failed parse.
Negative. xref.rs grows and gains a dependency on filter.rs for
Flate decoding at xref-parse time, which is earlier than decoding happened
before.
Risk. A subtly wrong /W decode yields plausible-looking offsets that
resolve to the wrong objects — worse than failing. Mitigated by fixtures
whose objects have asserted content, not merely a successful parse.
Out of scope, deliberately: writing xref streams or object streams,
cross-reference stream compression choices, and linearised-file
(/Linearized) fast-path reading.