Skip to main content

oxideav_pdf/reader/
xref.rs

1//! PDF cross-reference table + trailer parser (ISO 32000-1 §7.5.4–§7.5.5).
2//!
3//! Locates the `startxref` offset by scanning backwards from EOF
4//! (the `%%EOF` / `startxref` / xref-offset triple is always near
5//! the end of the file), then parses the cross-reference subsection
6//! list at that offset and the immediately-following trailer dict.
7//!
8//! Two flavours of cross-reference table are accepted:
9//!
10//! * **Plain xref** (PDF 1.0..1.4) — the `xref` keyword followed by
11//!   subsection headers and 20-byte entry lines, per §7.5.4.
12//! * **XRef stream** (PDF 1.5+, §7.5.8) — the startxref offset points
13//!   at an indirect object whose body is a stream with `/Type /XRef`,
14//!   `/W [w1 w2 w3]` field widths, optional `/Index`, and optional
15//!   `/Predictor 12` PNG-up filter on a `/FlateDecode` body. The
16//!   stream's dict carries the same trailer-dict slots as the plain
17//!   variant (`/Size`, `/Root`, `/Info`, `/Prev`, `/Encrypt`, `/ID`).
18//!
19//! **Hybrid-reference files** (§7.5.8.4) — a PDF whose `/Prev`-chained
20//! update trailer carries an `/XRefStm offset` entry alongside the
21//! classical `xref` subsections. PDF 1.5+ writers emit this shape to
22//! stay readable by pre-PDF-1.5 tools (which ignore `/XRefStm` and
23//! see only the classical entries) while letting modern readers find
24//! the compressed-object slots that are marked `free` in the
25//! classical table. The reader follows the spec's resolution order:
26//! the current section's classical entries first, then its `/XRefStm`
27//! entries, then `/Prev`. Newest wins on overlap, so a compressed-
28//! object slot that the classical table marks `free` is overridden
29//! by the corresponding `Compressed` entry from the `/XRefStm`.
30//!
31//! [`XrefTable`] turns into a [`Document`] of resolved indirect
32//! objects via the top-level walker — the intermediate type lets the
33//! reader resolve indirect references on demand without re-parsing
34//! every object up front.
35
36use std::collections::HashMap;
37
38use crate::error::PdfError;
39use crate::objects::{Dict, Object, ObjectId};
40use crate::reader::lex::{Lexer, TokenKind};
41use crate::reader::parse::Parser;
42
43/// One slot in the cross-reference table (§7.5.4 `Table 18` for plain
44/// xref; §7.5.8 `Table 18` for the XRef-stream form which adds
45/// `Compressed`).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum XrefEntry {
48    /// Free entry — points to the next free object via `next` and
49    /// carries the generation number to use if the slot is reused.
50    /// The head-of-list `0 65535 f` is the only `Free` we ever
51    /// expect to encounter in writer-generated PDFs.
52    Free { next: u32, generation: u16 },
53    /// In-use entry — the indirect object is at `offset` bytes from
54    /// the start of the file, with the given generation number.
55    InUse { offset: u64, generation: u16 },
56    /// Type 2 entry from an XRef stream — the object lives inside an
57    /// object-stream container at `obj_stream_id`, occupying the slot
58    /// at `index_within_stream`. The reader doesn't yet decode object
59    /// streams (PDF 1.5+ `/Type /ObjStm`), but recording the entry
60    /// keeps the xref shape lossless.
61    Compressed {
62        obj_stream_id: u32,
63        index_within_stream: u32,
64    },
65}
66
67/// A parsed cross-reference table + trailer dict.
68#[derive(Debug, Clone, Default)]
69pub struct XrefTable {
70    /// Object number → entry. Sparse — a 50-object PDF doesn't need
71    /// 50 vec slots if some are never referenced.
72    pub entries: HashMap<u32, XrefEntry>,
73    /// The trailer dictionary that follows the xref subsection list.
74    /// Carries `/Size`, `/Root`, optionally `/Info`, `/Prev`, `/ID`.
75    pub trailer: Dict,
76}
77
78impl XrefTable {
79    /// Look up the byte offset of an object by id. `None` if the id
80    /// doesn't appear in the xref or its slot is `Free` / `Compressed`
81    /// (the reader can't yet resolve compressed objects).
82    pub fn offset_of(&self, id: ObjectId) -> Option<u64> {
83        match self.entries.get(&id.number)? {
84            XrefEntry::InUse { offset, generation } if *generation == id.generation => {
85                Some(*offset)
86            }
87            _ => None,
88        }
89    }
90
91    /// Walk the trailer for the `/Root` reference. Returns
92    /// `PdfError::Other` when missing — every conforming PDF must
93    /// carry one (§7.5.5 Table 15).
94    pub fn root(&self) -> Result<ObjectId, PdfError> {
95        match self
96            .trailer
97            .entries()
98            .iter()
99            .find(|(k, _)| k == "Root")
100            .map(|(_, v)| v)
101        {
102            Some(Object::Reference(id)) => Ok(*id),
103            Some(other) => Err(PdfError::other(format!(
104                "PDF reader: trailer /Root must be an indirect reference (got {other:?})"
105            ))),
106            None => Err(PdfError::other(
107                "PDF reader: trailer is missing the required /Root entry",
108            )),
109        }
110    }
111
112    /// Optional `/Info` reference from the trailer. `None` when the
113    /// PDF has no document-level info dict.
114    pub fn info(&self) -> Option<ObjectId> {
115        self.trailer
116            .entries()
117            .iter()
118            .find(|(k, _)| k == "Info")
119            .and_then(|(_, v)| match v {
120                Object::Reference(id) => Some(*id),
121                _ => None,
122            })
123    }
124}
125
126/// Locate the `startxref` byte-offset by scanning backwards from EOF
127/// for the `startxref` keyword. The PDF spec requires the trailer to
128/// end within the last 1024 bytes (Acrobat convention) — we scan the
129/// last 4096 to be tolerant of unusually long trailers.
130pub fn find_startxref_offset(input: &[u8]) -> Result<u64, PdfError> {
131    if !input.contains(&b'%') {
132        return Err(PdfError::other(
133            "PDF reader: input has no `%` byte — does not look like a PDF",
134        ));
135    }
136    let scan_start = input.len().saturating_sub(4096);
137    let tail = &input[scan_start..];
138    let needle = b"startxref";
139    let local_pos = (0..tail.len().saturating_sub(needle.len()))
140        .rev()
141        .find(|&i| &tail[i..i + needle.len()] == needle)
142        .ok_or_else(|| {
143            PdfError::other(
144                "PDF reader: no `startxref` keyword in last 4096 bytes — file truncated?",
145            )
146        })?;
147    // Parse the integer that follows the keyword.
148    let mut p = Parser::new(&input[scan_start + local_pos + needle.len()..]);
149    let obj = p.parse_object()?.ok_or_else(|| {
150        PdfError::other("PDF reader: `startxref` keyword has no offset following it")
151    })?;
152    let Object::Integer(n) = obj else {
153        return Err(PdfError::other(format!(
154            "PDF reader: `startxref` offset must be an integer (got {obj:?})"
155        )));
156    };
157    if n < 0 {
158        return Err(PdfError::other(format!(
159            "PDF reader: `startxref` offset is negative ({n})"
160        )));
161    }
162    Ok(n as u64)
163}
164
165/// Parse the cross-reference table at `xref_offset` and the trailer
166/// dict that follows it. Accepts both the plain `xref`-keyword form
167/// (PDF 1.0..1.4, §7.5.4) and the XRef-stream form (PDF 1.5+, §7.5.8).
168pub fn parse_xref_at(input: &[u8], xref_offset: u64) -> Result<XrefTable, PdfError> {
169    let xref_pos = xref_offset as usize;
170    if xref_pos >= input.len() {
171        return Err(PdfError::other(format!(
172            "PDF reader: startxref offset {xref_offset} past end of file ({} bytes)",
173            input.len()
174        )));
175    }
176
177    let mut lex = Lexer::new(input);
178    lex.seek(xref_pos);
179
180    // First token decides the flavour: `xref` keyword → plain table,
181    // integer (the `<n> <gen> obj` of an XRef stream object) → §7.5.8.
182    let kw = lex
183        .next_token()?
184        .ok_or_else(|| PdfError::other("PDF reader: empty xref table"))?;
185    if let TokenKind::Integer(_) = kw.kind {
186        // XRef stream: re-anchor and parse the indirect object at the
187        // offset, then translate its body into a [`XrefTable`].
188        return parse_xref_stream_at(input, xref_pos);
189    }
190    let TokenKind::Keyword(b"xref") = kw.kind else {
191        return Err(PdfError::other(format!(
192            "PDF reader: expected `xref` keyword or XRef stream object at offset {xref_offset} (got {:?})",
193            kw.kind
194        )));
195    };
196
197    let mut entries: HashMap<u32, XrefEntry> = HashMap::new();
198    loop {
199        // Header line: `<first> <count>` integers, OR the `trailer`
200        // keyword that ends the xref table.
201        let next_tok = lex
202            .next_token()?
203            .ok_or_else(|| PdfError::other("PDF reader: truncated xref table"))?;
204        let first = match next_tok.kind {
205            TokenKind::Integer(n) => n,
206            TokenKind::Keyword(b"trailer") => break,
207            other => {
208                return Err(PdfError::other(format!(
209                    "PDF reader: expected xref subsection header or `trailer` (got {other:?}) at byte {}",
210                    next_tok.start
211                )));
212            }
213        };
214        let count_tok = lex
215            .next_token()?
216            .ok_or_else(|| PdfError::other("PDF reader: xref subsection has no count"))?;
217        let TokenKind::Integer(count) = count_tok.kind else {
218            return Err(PdfError::other(format!(
219                "PDF reader: xref subsection count must be an integer at byte {} (got {:?})",
220                count_tok.start, count_tok.kind
221            )));
222        };
223        if first < 0 || count < 0 {
224            return Err(PdfError::other(format!(
225                "PDF reader: negative xref subsection header `{first} {count}`"
226            )));
227        }
228        // Each entry is exactly 20 bytes per §7.5.4: 10-digit offset,
229        // ' ', 5-digit generation, ' ', 'n'/'f', 2-byte EOL. The
230        // lexer's whitespace handling makes byte-precise parsing
231        // tricky — we step the cursor and slice raw 20-byte windows.
232        // Skip any whitespace between the count and the first entry.
233        skip_whitespace(input, &mut lex);
234        for i in 0..count {
235            let off = lex.position();
236            if off + 20 > input.len() {
237                return Err(PdfError::other(format!(
238                    "PDF reader: xref entry {first}+{i} truncated at byte {off}"
239                )));
240            }
241            let entry = &input[off..off + 20];
242            let parsed = parse_xref_entry(entry, off)?;
243            entries.insert(first as u32 + i as u32, parsed);
244            lex.seek(off + 20);
245        }
246    }
247
248    // After the `trailer` keyword, the next object is the trailer dict.
249    let mut p = Parser::from_lexer(lex);
250    let dict_obj = p
251        .parse_object()?
252        .ok_or_else(|| PdfError::other("PDF reader: trailer dict missing"))?;
253    let Object::Dict(trailer) = dict_obj else {
254        return Err(PdfError::other(format!(
255            "PDF reader: trailer dict must be a dictionary (got {dict_obj:?})"
256        )));
257    };
258
259    Ok(XrefTable { entries, trailer })
260}
261
262/// One-shot top-level: scan startxref offset → parse xref table,
263/// then walk the trailer's `/Prev` chain (incremental updates;
264/// ISO 32000-1 §7.5.6) merging older sections beneath. The newest
265/// revision wins on overlap — a slot rewritten in revision N hides
266/// the same slot from revision N-1.
267///
268/// The trailer dict returned belongs to the newest revision; the
269/// `entries` map carries the merged view across all revisions.
270pub fn parse_xref(input: &[u8]) -> Result<XrefTable, PdfError> {
271    let mut current_off = find_startxref_offset(input)?;
272    let mut newest = parse_xref_at(input, current_off)?;
273    // The newest revision's table is correct for the slots it owns;
274    // we only need to *fill in* slots that the newer revision didn't
275    // re-declare.
276    let mut visited: std::collections::HashSet<u64> = std::collections::HashSet::new();
277    visited.insert(current_off);
278    // Hybrid-reference file (§7.5.8.4): if the newest revision's
279    // trailer carries an `/XRefStm offset`, merge the entries from
280    // that supplementary xref stream BEFORE walking `/Prev`. Per
281    // §7.5.8.4, the resolution order is "classical entries → XRefStm
282    // entries → older sections via /Prev" with the newer winning, so
283    // the merge uses `or_insert` to leave already-declared slots
284    // untouched. The XRefStm-stream's own trailer-shaped dict slots
285    // (/Root, /Size, /Encrypt, …) are intentionally NOT merged: the
286    // §7.5.8.4 spec exists precisely so pre-PDF-1.5 readers can rely
287    // on the classical trailer alone, and a hybrid file is only
288    // well-formed when both halves agree on those keys.
289    let xrefstm_visited = &mut visited.clone();
290    merge_xrefstm_if_present(
291        input,
292        &newest.trailer.clone(),
293        &mut newest.entries,
294        xrefstm_visited,
295    )?;
296    loop {
297        let prev_off = newest
298            .trailer
299            .entries()
300            .iter()
301            .find(|(k, _)| k == "Prev")
302            .and_then(|(_, v)| match v {
303                Object::Integer(n) if *n >= 0 => Some(*n as u64),
304                _ => None,
305            });
306        let Some(po) = prev_off else { break };
307        if !visited.insert(po) {
308            // Cycle in /Prev chain — refuse rather than loop forever.
309            return Err(PdfError::other(
310                "PDF reader: /Prev xref-section chain has a cycle",
311            ));
312        }
313        if visited.len() > 32 {
314            return Err(PdfError::other(
315                "PDF reader: /Prev xref-section chain exceeds 32 hops — refusing",
316            ));
317        }
318        let older = parse_xref_at(input, po)?;
319        // Merge older entries beneath — only fill slots the newer
320        // revision didn't declare. (HashMap::entry::or_insert
321        // semantics.)
322        for (id, entry) in older.entries {
323            newest.entries.entry(id).or_insert(entry);
324        }
325        // Hybrid-reference (§7.5.8.4) — older sections may also carry
326        // `/XRefStm` (the spec only bars it from the *main* section);
327        // resolve before stepping further back via /Prev. Newer-wins
328        // still applies, so the XRefStm only fills gaps.
329        merge_xrefstm_if_present(input, &older.trailer, &mut newest.entries, xrefstm_visited)?;
330        // Move /Prev into the in-progress trailer so the next loop
331        // iteration sees the older section's /Prev (chains can be
332        // longer than one hop).
333        let mut next_trailer = older.trailer.clone();
334        // Strip /Prev so we don't re-walk indefinitely if the older
335        // section happened not to carry one. We keep the merged
336        // table's trailer pointed at the newest revision's dict
337        // values (above) — older.trailer is only used for its /Prev.
338        next_trailer.set("Prev", Object::Null);
339        // Record the older section's /Prev (if any) on the newest
340        // table so the next loop iteration walks one more step.
341        let older_prev = older
342            .trailer
343            .entries()
344            .iter()
345            .find(|(k, _)| k == "Prev")
346            .and_then(|(_, v)| match v {
347                Object::Integer(n) if *n >= 0 => Some(*n as u64),
348                _ => None,
349            });
350        // Replace newest.trailer's /Prev with whatever the older
351        // section pointed at (or remove it once chain ends).
352        let mut new_trailer = Dict::new();
353        for (k, v) in newest.trailer.entries() {
354            if k != "Prev" {
355                new_trailer.set(k, v.clone());
356            }
357        }
358        if let Some(op) = older_prev {
359            new_trailer.set("Prev", Object::Integer(op as i64));
360        }
361        newest.trailer = new_trailer;
362        current_off = po;
363    }
364    let _ = current_off;
365    Ok(newest)
366}
367
368/// Resolve the `/XRefStm` entry of `trailer` (if any), parse the
369/// supplementary xref stream at that offset, and merge its entries
370/// into `into` using `or_insert` (newer-wins). `visited` records
371/// already-consulted offsets so a malicious `/XRefStm` cycle can't
372/// loop forever; the visited set is shared with the `/Prev` walker
373/// so cross-cycles are also caught.
374///
375/// ISO 32000-1 §7.5.8.4 ("Compatibility with Applications That Do
376/// Not Support Compressed Reference Streams"): hybrid-reference files
377/// place compressed-object slots in an XRef stream while keeping a
378/// classical xref subsection visible to pre-1.5 readers. The classical
379/// entries mark the compressed objects as `free` (so old readers
380/// resolve them to null), and the XRefStm carries the actual
381/// `Compressed` slots that a modern reader needs to look up.
382fn merge_xrefstm_if_present(
383    input: &[u8],
384    trailer: &Dict,
385    into: &mut HashMap<u32, XrefEntry>,
386    visited: &mut std::collections::HashSet<u64>,
387) -> Result<(), PdfError> {
388    let xrefstm_off = trailer
389        .entries()
390        .iter()
391        .find(|(k, _)| k == "XRefStm")
392        .and_then(|(_, v)| match v {
393            Object::Integer(n) if *n >= 0 => Some(*n as u64),
394            _ => None,
395        });
396    let Some(off) = xrefstm_off else {
397        return Ok(());
398    };
399    // Refuse cycles (an /XRefStm that points back at an already-
400    // visited classical section or another XRefStm we've already
401    // merged would loop forever).
402    if !visited.insert(off) {
403        return Err(PdfError::other(
404            "PDF reader: /XRefStm offset already visited (cycle in hybrid-reference chain)",
405        ));
406    }
407    if visited.len() > 32 {
408        return Err(PdfError::other(
409            "PDF reader: /XRefStm chain exceeds 32 hops — refusing",
410        ));
411    }
412    if off as usize >= input.len() {
413        return Err(PdfError::other(format!(
414            "PDF reader: /XRefStm offset {off} past end of file ({} bytes)",
415            input.len()
416        )));
417    }
418    let supp = parse_xref_stream_at(input, off as usize)?;
419    for (id, entry) in supp.entries {
420        into.entry(id).or_insert(entry);
421    }
422    Ok(())
423}
424
425fn skip_whitespace(input: &[u8], lex: &mut Lexer<'_>) {
426    let mut p = lex.position();
427    while p < input.len()
428        && (input[p] == b' ' || input[p] == b'\t' || input[p] == b'\r' || input[p] == b'\n')
429    {
430        p += 1;
431    }
432    lex.seek(p);
433}
434
435fn parse_xref_entry(bytes: &[u8], at: usize) -> Result<XrefEntry, PdfError> {
436    debug_assert_eq!(bytes.len(), 20);
437    // Format: NNNNNNNNNN GGGGG (n|f) EOL  (10 + 1 + 5 + 1 + 1 + 2 = 20)
438    if bytes[10] != b' ' || bytes[16] != b' ' {
439        return Err(PdfError::other(format!(
440            "PDF reader: malformed xref entry at byte {at} (missing space separators)"
441        )));
442    }
443    let off_str = std::str::from_utf8(&bytes[..10])
444        .map_err(|_| PdfError::other(format!("PDF reader: non-ASCII xref offset at byte {at}")))?;
445    let off: u64 = off_str.trim().parse().map_err(|_| {
446        PdfError::other(format!(
447            "PDF reader: invalid xref offset `{off_str}` at byte {at}"
448        ))
449    })?;
450    let gen_str = std::str::from_utf8(&bytes[11..16]).map_err(|_| {
451        PdfError::other(format!(
452            "PDF reader: non-ASCII xref generation at byte {at}"
453        ))
454    })?;
455    let generation: u16 = gen_str.trim().parse().map_err(|_| {
456        PdfError::other(format!(
457            "PDF reader: invalid xref generation `{gen_str}` at byte {at}"
458        ))
459    })?;
460    let kind = bytes[17];
461    match kind {
462        b'n' => Ok(XrefEntry::InUse {
463            offset: off,
464            generation,
465        }),
466        b'f' => Ok(XrefEntry::Free {
467            next: off as u32,
468            generation,
469        }),
470        other => Err(PdfError::other(format!(
471            "PDF reader: xref entry kind must be `n` or `f` at byte {at} (got `{}`)",
472            other as char
473        ))),
474    }
475}
476
477/// Parse a PDF 1.5+ XRef stream object (§7.5.8) at the given byte
478/// offset. Returns the same [`XrefTable`] shape the plain-xref parser
479/// produces — the trailer dict pulls from the stream object's own
480/// dictionary, and entries are decoded from the binary `/W`-formatted
481/// body (after applying `/Filter` decoding + `/DecodeParms /Predictor`
482/// reversal where present).
483fn parse_xref_stream_at(input: &[u8], xref_pos: usize) -> Result<XrefTable, PdfError> {
484    let mut p = Parser::new(input);
485    p.lexer_mut().seek(xref_pos);
486    let (_obj_id, body) = p.parse_indirect()?;
487    let stream = match body {
488        Object::Stream(s) => s,
489        other => {
490            return Err(PdfError::other(format!(
491                "PDF reader: XRef stream object must be a Stream (got {other:?})"
492            )));
493        }
494    };
495
496    // The stream dict carries:
497    //   /Type /XRef
498    //   /Size  N            (one past the largest object number)
499    //   /W     [w1 w2 w3]   (byte widths of the three fields per entry)
500    //   /Index [s1 c1 ...]  (subsection list; default [0 Size])
501    //   /Filter /FlateDecode
502    //   /DecodeParms << /Predictor 12 /Columns N >> (optional)
503    //   plus the standard trailer keys: /Root, /Info, /Encrypt, /Prev, /ID
504    let dict = &stream.dict;
505    let lookup = |k: &str| {
506        dict.entries()
507            .iter()
508            .find(|(kk, _)| kk == k)
509            .map(|(_, v)| v.clone())
510    };
511
512    if !matches!(lookup("Type"), Some(Object::Name(ref n)) if n == "XRef") {
513        return Err(PdfError::other(
514            "PDF reader: XRef stream object missing /Type /XRef",
515        ));
516    }
517    let size = match lookup("Size") {
518        Some(Object::Integer(n)) if n >= 0 => n as u32,
519        _ => return Err(PdfError::other("PDF reader: XRef stream missing /Size")),
520    };
521    let w = match lookup("W") {
522        Some(Object::Array(items)) if items.len() == 3 => {
523            let mut out = [0usize; 3];
524            for (i, it) in items.iter().enumerate() {
525                let Object::Integer(v) = it else {
526                    return Err(PdfError::other(format!(
527                        "PDF reader: XRef /W[{i}] must be an integer (got {it:?})"
528                    )));
529                };
530                if *v < 0 || *v > 8 {
531                    return Err(PdfError::other(format!(
532                        "PDF reader: XRef /W[{i}] = {v} out of range [0..=8]"
533                    )));
534                }
535                out[i] = *v as usize;
536            }
537            out
538        }
539        other => {
540            return Err(PdfError::other(format!(
541                "PDF reader: XRef stream /W must be a 3-array (got {other:?})"
542            )));
543        }
544    };
545    let index: Vec<(u32, u32)> = match lookup("Index") {
546        Some(Object::Array(items)) => {
547            if items.len() % 2 != 0 {
548                return Err(PdfError::other(
549                    "PDF reader: XRef /Index array length must be even",
550                ));
551            }
552            items
553                .chunks_exact(2)
554                .map(|chunk| {
555                    let (Object::Integer(s), Object::Integer(c)) = (&chunk[0], &chunk[1]) else {
556                        return Err(PdfError::other(
557                            "PDF reader: XRef /Index entries must be integers",
558                        ));
559                    };
560                    if *s < 0 || *c < 0 {
561                        return Err(PdfError::other(
562                            "PDF reader: XRef /Index entries must be non-negative",
563                        ));
564                    }
565                    Ok((*s as u32, *c as u32))
566                })
567                .collect::<Result<_, _>>()?
568        }
569        Some(other) => {
570            return Err(PdfError::other(format!(
571                "PDF reader: XRef /Index must be an array (got {other:?})"
572            )));
573        }
574        None => vec![(0, size)],
575    };
576
577    // Step 1: apply /Filter (FlateDecode is the only one writers use).
578    let raw = decode_xref_stream_body(&stream)?;
579
580    // Step 2: undo predictor (PNG-up, /Predictor 12) if requested.
581    let table_bytes = apply_predictor(&raw, dict, w[0] + w[1] + w[2])?;
582
583    // Step 3: walk the binary table.
584    let entry_size = w[0] + w[1] + w[2];
585    if entry_size == 0 {
586        return Err(PdfError::other(
587            "PDF reader: XRef stream /W = [0 0 0] is degenerate",
588        ));
589    }
590    let mut entries: HashMap<u32, XrefEntry> = HashMap::new();
591    let mut cursor = 0usize;
592    for (start, count) in &index {
593        for offset_in_section in 0..*count {
594            if cursor + entry_size > table_bytes.len() {
595                return Err(PdfError::other(format!(
596                    "PDF reader: XRef stream truncated at entry {start}+{offset_in_section} \
597                     (cursor {cursor}, need {entry_size}, have {})",
598                    table_bytes.len()
599                )));
600            }
601            let chunk = &table_bytes[cursor..cursor + entry_size];
602            cursor += entry_size;
603            let (f1, f2, f3) = split_fields(chunk, w[0], w[1], w[2]);
604            // f1 default = 1 when w[0] == 0 (§7.5.8.3 W array note:
605            // "If the first element is zero, the type field shall not
606            // be present, and shall default to type 1").
607            let kind = if w[0] == 0 { 1 } else { f1 };
608            let id = start + offset_in_section;
609            let entry = match kind {
610                0 => XrefEntry::Free {
611                    // f2 = next free obj number; f3 = generation.
612                    next: f2 as u32,
613                    generation: f3 as u16,
614                },
615                1 => XrefEntry::InUse {
616                    offset: f2,
617                    // /W default for w[2] is 0, in which case the spec
618                    // says "0" generation (Table 18 Type 1 field 3
619                    // "Default value: 0").
620                    generation: f3 as u16,
621                },
622                2 => XrefEntry::Compressed {
623                    obj_stream_id: f2 as u32,
624                    index_within_stream: f3 as u32,
625                },
626                _ => {
627                    // §7.5.8.3: "In PDF 1.5 through PDF 1.7, only types
628                    // 0, 1, and 2 are allowed. Any other value shall be
629                    // interpreted as a reference to the null object,
630                    // thus permitting new entry types to be defined in
631                    // the future." A null reference resolves like a free
632                    // slot — the resolver returns no offset for it — so
633                    // we record the entry as Free with the head-of-list
634                    // "never reusable" generation 65535.
635                    XrefEntry::Free {
636                        next: 0,
637                        generation: 65535,
638                    }
639                }
640            };
641            entries.insert(id, entry);
642        }
643    }
644
645    // The stream dict itself is the trailer dict (§7.5.8.2). Strip
646    // entries that don't belong in a trailer (Length, Filter, etc.) so
647    // downstream code can iterate it like a plain trailer.
648    let trailer = filter_trailer_dict(dict);
649
650    Ok(XrefTable { entries, trailer })
651}
652
653/// Apply the stream's `/Filter` to recover the raw xref bytes.
654fn decode_xref_stream_body(stream: &crate::objects::Stream) -> Result<Vec<u8>, PdfError> {
655    let filter = stream
656        .dict
657        .entries()
658        .iter()
659        .find(|(k, _)| k == "Filter")
660        .map(|(_, v)| v.clone());
661    match filter {
662        None => Ok(stream.data.clone()),
663        Some(Object::Name(n)) if n == "FlateDecode" => crate::zlib::flate_decompress(&stream.data)
664            .map_err(|e| {
665                PdfError::other(format!("PDF reader: XRef stream FlateDecode failed: {e}"))
666            }),
667        Some(Object::Array(items)) => {
668            // Filter chain — only FlateDecode is supported here.
669            let mut data = stream.data.clone();
670            for it in items {
671                let Object::Name(n) = it else {
672                    return Err(PdfError::other(
673                        "PDF reader: XRef stream /Filter chain item must be a Name",
674                    ));
675                };
676                if n != "FlateDecode" {
677                    return Err(PdfError::other(format!(
678                        "PDF reader: XRef stream filter `{n}` not supported"
679                    )));
680                }
681                data = crate::zlib::flate_decompress(&data).map_err(|e| {
682                    PdfError::other(format!("PDF reader: XRef stream FlateDecode failed: {e}"))
683                })?;
684            }
685            Ok(data)
686        }
687        Some(Object::Name(n)) => Err(PdfError::other(format!(
688            "PDF reader: XRef stream filter `{n}` not supported"
689        ))),
690        Some(other) => Err(PdfError::other(format!(
691            "PDF reader: XRef stream /Filter must be a Name or array (got {other:?})"
692        ))),
693    }
694}
695
696/// Reverse PNG predictor 12 (PNG-up). The `up` predictor stores each
697/// row as the byte-wise XOR difference from the previous row; the
698/// stream's `/DecodeParms /Predictor` selects the active predictor and
699/// `/Columns` gives the row width (in `/W` entry-bytes here).
700///
701/// Predictor values per §7.4.4.4:
702/// * 1 = none (no transformation; pass through),
703/// * 2 = TIFF predictor 2 (left differences — uncommon for xref),
704/// * 10..=15 = PNG predictors with a 1-byte tag prefix per row.
705///   Predictor 12 = PNG-Up. Predictor 15 = "optimum" — every row's
706///   tag picks one of the five PNG predictors.
707fn apply_predictor(raw: &[u8], dict: &Dict, entry_width: usize) -> Result<Vec<u8>, PdfError> {
708    let parms = dict.entries().iter().find(|(k, _)| k == "DecodeParms");
709    let Some((_, parms_obj)) = parms else {
710        // No DecodeParms — assume Predictor 1 (no transformation).
711        return Ok(raw.to_vec());
712    };
713    let Object::Dict(parms_dict) = parms_obj else {
714        return Err(PdfError::other("PDF reader: /DecodeParms must be a dict"));
715    };
716    let predictor = parms_dict
717        .entries()
718        .iter()
719        .find(|(k, _)| k == "Predictor")
720        .map(|(_, v)| v.clone());
721    let columns = parms_dict
722        .entries()
723        .iter()
724        .find(|(k, _)| k == "Columns")
725        .map(|(_, v)| v.clone());
726    let p = match predictor {
727        Some(Object::Integer(n)) => n,
728        None => 1,
729        other => {
730            return Err(PdfError::other(format!(
731                "PDF reader: /Predictor must be an integer (got {other:?})"
732            )));
733        }
734    };
735    if p == 1 {
736        return Ok(raw.to_vec());
737    }
738    let columns = match columns {
739        Some(Object::Integer(n)) if n > 0 => n as usize,
740        Some(other) => {
741            return Err(PdfError::other(format!(
742                "PDF reader: /Columns must be a positive integer (got {other:?})"
743            )));
744        }
745        // Default per §7.4.4.4 is 1, but for XRef streams the columns
746        // width is the per-entry width.
747        None => entry_width,
748    };
749    if !(10..=15).contains(&p) {
750        return Err(PdfError::other(format!(
751            "PDF reader: /Predictor {p} not yet supported (only PNG predictors 10..=15)"
752        )));
753    }
754    // PNG predictors store one tag byte per row + `columns` data bytes.
755    let row_size = columns + 1;
756    if raw.len() % row_size != 0 {
757        return Err(PdfError::other(format!(
758            "PDF reader: predictor row size {row_size} doesn't divide raw len {}",
759            raw.len()
760        )));
761    }
762    let row_count = raw.len() / row_size;
763    let mut out = Vec::with_capacity(row_count * columns);
764    let mut prev_row = vec![0u8; columns];
765    for row_idx in 0..row_count {
766        let row = &raw[row_idx * row_size..(row_idx + 1) * row_size];
767        let tag = row[0];
768        let data = &row[1..];
769        let mut decoded_row = vec![0u8; columns];
770        match tag {
771            0 => {
772                // None.
773                decoded_row.copy_from_slice(data);
774            }
775            1 => {
776                // Sub: each byte = data[i] + decoded[i-1].
777                for i in 0..columns {
778                    let left = if i == 0 { 0 } else { decoded_row[i - 1] };
779                    decoded_row[i] = data[i].wrapping_add(left);
780                }
781            }
782            2 => {
783                // Up: data[i] + prev_row[i].
784                for i in 0..columns {
785                    decoded_row[i] = data[i].wrapping_add(prev_row[i]);
786                }
787            }
788            3 => {
789                // Average: data[i] + floor((left + up) / 2).
790                for i in 0..columns {
791                    let left = if i == 0 {
792                        0u16
793                    } else {
794                        decoded_row[i - 1] as u16
795                    };
796                    let up = prev_row[i] as u16;
797                    decoded_row[i] = data[i].wrapping_add(((left + up) / 2) as u8);
798                }
799            }
800            4 => {
801                // Paeth.
802                for i in 0..columns {
803                    let left = if i == 0 {
804                        0i16
805                    } else {
806                        decoded_row[i - 1] as i16
807                    };
808                    let up = prev_row[i] as i16;
809                    let upper_left = if i == 0 { 0i16 } else { prev_row[i - 1] as i16 };
810                    let p_pred = paeth_predictor(left, up, upper_left);
811                    decoded_row[i] = data[i].wrapping_add(p_pred);
812                }
813            }
814            other => {
815                return Err(PdfError::other(format!(
816                    "PDF reader: PNG predictor row tag {other} unknown"
817                )));
818            }
819        }
820        out.extend_from_slice(&decoded_row);
821        prev_row = decoded_row;
822    }
823    Ok(out)
824}
825
826fn paeth_predictor(a: i16, b: i16, c: i16) -> u8 {
827    let p = a + b - c;
828    let pa = (p - a).abs();
829    let pb = (p - b).abs();
830    let pc = (p - c).abs();
831    let r = if pa <= pb && pa <= pc {
832        a
833    } else if pb <= pc {
834        b
835    } else {
836        c
837    };
838    r as u8
839}
840
841/// Read three big-endian integer fields of variable byte width from a
842/// chunk of bytes. Sized like a u64 — XRef field widths are bounded
843/// at 8 bytes per §7.5.8.3.
844fn split_fields(chunk: &[u8], w1: usize, w2: usize, w3: usize) -> (u64, u64, u64) {
845    fn read_be(s: &[u8]) -> u64 {
846        let mut v: u64 = 0;
847        for &b in s {
848            v = (v << 8) | (b as u64);
849        }
850        v
851    }
852    let f1 = read_be(&chunk[..w1]);
853    let f2 = read_be(&chunk[w1..w1 + w2]);
854    let f3 = read_be(&chunk[w1 + w2..w1 + w2 + w3]);
855    (f1, f2, f3)
856}
857
858/// Strip stream-only keys from an XRef-stream dictionary so it's safe
859/// to treat as a trailer. The omitted keys are the ones that describe
860/// the stream payload itself, not document-level metadata.
861fn filter_trailer_dict(dict: &Dict) -> Dict {
862    let stream_only = [
863        "Type",
864        "Filter",
865        "DecodeParms",
866        "Length",
867        "F",
868        "FFilter",
869        "FDecodeParms",
870        "DL",
871        "W",
872        "Index",
873    ];
874    let mut out = Dict::new();
875    for (k, v) in dict.entries() {
876        if !stream_only.contains(&k.as_str()) {
877            out.set(k, v.clone());
878        }
879    }
880    out
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use crate::writer::write_pdf;
887    use oxideav_core::time::TimeBase;
888    use oxideav_core::vector::{
889        FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
890    };
891
892    fn sample_pdf_bytes() -> Vec<u8> {
893        let mut p = Path::new();
894        p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
895        p.commands.push(PathCommand::LineTo(Point::new(90.0, 10.0)));
896        p.commands.push(PathCommand::LineTo(Point::new(90.0, 90.0)));
897        p.commands.push(PathCommand::Close);
898        let frame = VectorFrame {
899            width: 100.0,
900            height: 100.0,
901            view_box: None,
902            root: Group {
903                children: vec![Node::Path(PathNode {
904                    path: p,
905                    fill: Some(Paint::Solid(Rgba::opaque(0, 128, 255))),
906                    stroke: None,
907                    fill_rule: FillRule::NonZero,
908                })],
909                ..Group::default()
910            },
911            pts: None,
912            time_base: TimeBase::new(1, 1),
913        };
914        write_pdf(&frame).expect("write_pdf")
915    }
916
917    #[test]
918    fn finds_startxref_in_writer_output() {
919        let pdf = sample_pdf_bytes();
920        let off = find_startxref_offset(&pdf).expect("startxref");
921        assert!(off > 0);
922        // The xref keyword must live exactly there.
923        assert_eq!(&pdf[off as usize..off as usize + 4], b"xref");
924    }
925
926    #[test]
927    fn parses_xref_table_for_writer_output() {
928        let pdf = sample_pdf_bytes();
929        let table = parse_xref(&pdf).expect("parse_xref");
930        // Round-1 single-page docs have 5 indirect objects (catalog,
931        // pages, page, resources, contents) — id 0 is the free-list
932        // head, so entries count = 6.
933        assert!(table.entries.len() >= 5);
934        // The free-list head at id 0.
935        assert!(matches!(
936            table.entries.get(&0),
937            Some(XrefEntry::Free {
938                generation: 65535,
939                ..
940            })
941        ));
942        // Every other entry is in-use.
943        for i in 1..=5 {
944            assert!(
945                matches!(table.entries.get(&i), Some(XrefEntry::InUse { .. })),
946                "entry {i} should be InUse"
947            );
948        }
949        // Trailer references /Root → catalog (id 1).
950        let root = table.root().expect("trailer /Root");
951        assert_eq!(root.number, 1);
952    }
953
954    #[test]
955    fn xref_offset_lookup_round_trips() {
956        let pdf = sample_pdf_bytes();
957        let table = parse_xref(&pdf).expect("parse_xref");
958        // Each in-use entry's offset must point at the start of the
959        // matching `<n> <gen> obj` header.
960        for (id_num, entry) in &table.entries {
961            if let XrefEntry::InUse { offset, generation } = entry {
962                let pos = *offset as usize;
963                assert!(pos < pdf.len(), "offset out of range for id {id_num}");
964                let expected = format!("{} {} obj", id_num, generation);
965                let slice = &pdf[pos..(pos + expected.len()).min(pdf.len())];
966                assert_eq!(
967                    slice,
968                    expected.as_bytes(),
969                    "object {id_num} {generation} obj should be at offset {offset}"
970                );
971            }
972        }
973    }
974
975    #[test]
976    fn root_required_for_well_formed_pdf() {
977        let pdf = sample_pdf_bytes();
978        let table = parse_xref(&pdf).expect("parse_xref");
979        let _ = table.root().expect("/Root must resolve");
980    }
981
982    #[test]
983    fn info_optional() {
984        // The round-1 writer doesn't emit an /Info entry, so this PDF
985        // returns None.
986        let pdf = sample_pdf_bytes();
987        let table = parse_xref(&pdf).expect("parse_xref");
988        assert!(table.info().is_none());
989    }
990
991    #[test]
992    fn rejects_truncated_input() {
993        let pdf = b"not even a pdf";
994        let r = parse_xref(pdf);
995        assert!(r.is_err());
996    }
997
998    #[test]
999    fn rejects_startxref_off_end_of_file() {
1000        // Locate the startxref byte position in the writer's output
1001        // (PDF starts with the binary marker so `from_utf8` would
1002        // fail — we scan as bytes).
1003        let mut pdf = sample_pdf_bytes();
1004        let needle = b"startxref";
1005        let pos = pdf
1006            .windows(needle.len())
1007            .rposition(|w| w == needle)
1008            .expect("startxref present");
1009        // Truncate at the keyword and re-append a huge offset.
1010        pdf.truncate(pos);
1011        pdf.extend_from_slice(b"startxref\n999999999\n%%EOF\n");
1012        let r = parse_xref(&pdf);
1013        assert!(r.is_err());
1014    }
1015}