Skip to main content

pdfboss_core/
xref.rs

1//! Cross-reference loading (ISO 32000 §7.5): classic tables, xref streams,
2//! hybrid files (`/XRefStm`), `/Prev` chains, and a whole-file recovery scan
3//! when everything else fails.
4
5use crate::hash::{FastMap, FastSet};
6
7use crate::elements::{Span, XrefKind};
8use crate::error::{Error, Result};
9use crate::filters::{decode_stream, is_pdf_whitespace};
10use crate::lexer::{Lexer, Token};
11use crate::object::{Dict, Name, ObjRef, Object};
12use crate::parser::{NoResolve, Parser};
13
14/// One cross-reference table entry.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum XrefEntry {
17    /// A free entry.
18    Free,
19    /// An object stored directly in the file at `offset`.
20    InFile { offset: u64, gen: u16 },
21    /// An object stored inside object stream `stream_num` at `index`.
22    InStream { stream_num: u32, index: u32 },
23}
24
25/// The merged cross-reference table plus the (merged) trailer dictionary.
26#[derive(Debug, Clone, Default)]
27pub struct Xref {
28    map: FastMap<u32, XrefEntry>,
29    /// Merged trailer dictionary; keys from newer sections win.
30    pub trailer: Dict,
31}
32
33impl Xref {
34    /// Looks up the entry for object number `num`.
35    pub fn get(&self, num: u32) -> Option<XrefEntry> {
36        self.map.get(&num).copied()
37    }
38
39    /// Inserts `entry` for `num` unless an entry is already present.
40    fn add(&mut self, num: u32, entry: XrefEntry) {
41        self.map.entry(num).or_insert(entry);
42    }
43
44    /// Merges an older section into this one. Entries already present win
45    /// (sections are walked newest to oldest, first-seen wins); trailer
46    /// keys already present are kept.
47    pub fn merge(&mut self, older: Xref) {
48        for (num, entry) in older.map {
49            self.map.entry(num).or_insert(entry);
50        }
51        for (key, value) in older.trailer.iter() {
52            if self.trailer.get(&key.0).is_none() {
53                self.trailer.insert(key.clone(), value.clone());
54            }
55        }
56    }
57
58    /// Iterates all `(object number, entry)` pairs in unspecified order.
59    pub fn iter(&self) -> impl Iterator<Item = (u32, XrefEntry)> + '_ {
60        self.map.iter().map(|(&num, &entry)| (num, entry))
61    }
62
63    /// Number of entries.
64    pub fn len(&self) -> usize {
65        self.map.len()
66    }
67
68    /// Whether the table has no entries.
69    pub fn is_empty(&self) -> bool {
70        self.map.is_empty()
71    }
72}
73
74/// Loads the cross-reference data for a whole file: locates `startxref`
75/// (last 1 KiB, widening to the last 64 KiB), parses the classic table or
76/// xref stream found there, follows `/XRefStm` and `/Prev` with a
77/// visited-offset loop guard, and on any failure falls back to a whole-file
78/// recovery scan for `N G obj` headers.
79pub fn load_xref(data: &[u8]) -> Result<Xref> {
80    let chained = find_startxref(data).and_then(|start| load_chain(data, start).ok());
81    match chained {
82        Some(xref) if xref.trailer.get("Root").is_some() => Ok(xref),
83        chained => recovery_scan(data).or_else(|err| chained.ok_or(err)),
84    }
85}
86
87/// The byte offset of the file's newest cross-reference section, as the
88/// trailing `startxref` announces it; `None` when the file has none. An
89/// incremental update names it as the `/Prev` of the section it appends.
90pub fn startxref(data: &[u8]) -> Option<usize> {
91    find_startxref(data)
92}
93
94/// Finds the byte offset announced after the last `startxref` keyword,
95/// searching the last 1 KiB first and widening to the last 64 KiB when the
96/// keyword is absent from the smaller window.
97fn find_startxref(data: &[u8]) -> Option<usize> {
98    for window in [1024usize, 64 * 1024] {
99        let tail = data.len().saturating_sub(window);
100        if let Some(rel) = memchr::memmem::rfind(&data[tail..], b"startxref") {
101            let mut lexer = Lexer::at(data, tail + rel + b"startxref".len());
102            if let Ok(Token::Int(v)) = lexer.next_token() {
103                if let Some(offset) = to_offset(v, data) {
104                    return Some(offset);
105                }
106            }
107        }
108        if window >= data.len() {
109            break;
110        }
111    }
112    None
113}
114
115/// Converts an integer file offset to `usize` when it lies inside `data`.
116fn to_offset(v: i64, data: &[u8]) -> Option<usize> {
117    usize::try_from(v).ok().filter(|&o| o < data.len())
118}
119
120/// Walks the section chain newest→oldest starting at `start`, merging every
121/// section into one table (first-seen entries win). A classic trailer's
122/// `/XRefStm` section (hybrid file) merges ahead of its table — the table
123/// marks the stream's objects free to hide them from old readers — and both
124/// merge before `/Prev` is followed. Visited offsets guard against loops.
125fn load_chain(data: &[u8], start: usize) -> Result<Xref> {
126    let mut acc = Xref::default();
127    let mut visited: FastSet<usize> = FastSet::default();
128    let mut next = Some(start);
129    while let Some(off) = next {
130        if !visited.insert(off) {
131            break;
132        }
133        let info = parse_section_at(data, off)?;
134        if let Some(xs) = info.xrefstm.and_then(|v| to_offset(v, data)) {
135            if visited.insert(xs) {
136                // Lenient: a broken hybrid stream leaves the table alone.
137                if let Ok(stream_info) = parse_section_at(data, xs) {
138                    acc.merge(stream_info.xref);
139                }
140            }
141        }
142        acc.merge(info.xref);
143        next = info.prev.and_then(|v| to_offset(v, data));
144    }
145    if acc.map.is_empty() {
146        Err(Error::InvalidXref)
147    } else {
148        Ok(acc)
149    }
150}
151
152/// Big-endian integer from up to 8 bytes; an empty slice reads as 0.
153fn read_be(bytes: &[u8]) -> u64 {
154    bytes.iter().fold(0, |acc, &b| (acc << 8) | u64::from(b))
155}
156
157/// One parsed cross-reference section with its byte extents, for element
158/// iteration and for chain walkers that fetch byte ranges on demand.
159#[derive(Debug, Clone)]
160pub struct XrefSectionInfo {
161    /// This section's entries plus its trailer keys.
162    pub xref: Xref,
163    pub kind: XrefKind,
164    /// The trailer's `/Prev` value, when present.
165    pub prev: Option<i64>,
166    /// The classic trailer's `/XRefStm` value (hybrid files), when present.
167    pub xrefstm: Option<i64>,
168    /// Byte range of the section itself: the `xref` table (excluding its
169    /// trailer) or the whole cross-reference stream object.
170    pub span: Span,
171    /// Classic sections: byte range of `trailer << … >>`. Stream sections
172    /// have no separate trailer region.
173    pub trailer_span: Option<Span>,
174}
175
176/// Parses the cross-reference section at `off` — a classic table or a
177/// cross-reference stream — reporting entries, chain pointers, and spans.
178pub fn parse_section_at(data: &[u8], off: usize) -> Result<XrefSectionInfo> {
179    let mut lexer = Lexer::at(data, off);
180    if matches!(lexer.peek_token(), Ok(Token::Keyword(ref k)) if k.as_slice() == b"xref") {
181        parse_classic(data, off)
182    } else {
183        parse_stream_section(data, off)
184    }
185}
186
187/// Parses a classic `xref` section at `off`: `start count` subsection
188/// headers, then `count` entries each of `offset gen n|f`, ending with
189/// `trailer` and its dictionary. Entries are read token-wise, so malformed
190/// 19- or 21-byte entry lines load just as well as conforming 20-byte ones.
191fn parse_classic(data: &[u8], off: usize) -> Result<XrefSectionInfo> {
192    let mut lexer = Lexer::at(data, off);
193    match lexer.next_token()? {
194        Token::Keyword(ref k) if k.as_slice() == b"xref" => {}
195        _ => return Err(Error::InvalidXref),
196    }
197    let mut section = Xref::default();
198    loop {
199        match lexer.next_token()? {
200            Token::Int(start) if start >= 0 => {
201                let count = match lexer.next_token()? {
202                    Token::Int(c) if c >= 0 => c as u64,
203                    _ => return Err(Error::InvalidXref),
204                };
205                // Even a degenerate entry line needs at least 11 bytes, so
206                // a count beyond this bound cannot be real.
207                if count > data.len() as u64 / 11 + 1 {
208                    return Err(Error::InvalidXref);
209                }
210                for i in 0..count {
211                    let f1 = match lexer.next_token()? {
212                        Token::Int(v) if v >= 0 => v as u64,
213                        _ => return Err(Error::InvalidXref),
214                    };
215                    let f2 = match lexer.next_token()? {
216                        Token::Int(v) if v >= 0 => v,
217                        _ => return Err(Error::InvalidXref),
218                    };
219                    let entry = match lexer.next_token()? {
220                        Token::Keyword(ref k) if k.as_slice() == b"n" => XrefEntry::InFile {
221                            offset: f1,
222                            gen: f2.min(65535) as u16,
223                        },
224                        Token::Keyword(ref k) if k.as_slice() == b"f" => XrefEntry::Free,
225                        _ => return Err(Error::InvalidXref),
226                    };
227                    if let Ok(num) = u32::try_from(start as u64 + i) {
228                        section.add(num, entry);
229                    }
230                }
231            }
232            Token::Keyword(ref k) if k.as_slice() == b"trailer" => {
233                // The keyword itself ends at lexer.pos(); back up its length
234                // to find where `trailer` (and thus the table span) starts.
235                let trailer_start = lexer.pos() - b"trailer".len();
236                let mut parser = Parser::at(data, lexer.pos());
237                let trailer = match parser.parse_object(&NoResolve)? {
238                    Object::Dict(d) => d,
239                    _ => return Err(Error::InvalidXref),
240                };
241                let prev = trailer.get_int("Prev");
242                let xrefstm = trailer.get_int("XRefStm");
243                section.trailer = trailer;
244                return Ok(XrefSectionInfo {
245                    xref: section,
246                    kind: XrefKind::Table,
247                    prev,
248                    xrefstm,
249                    span: Span::new(off as u64, trailer_start as u64),
250                    trailer_span: Some(Span::new(trailer_start as u64, parser.pos() as u64)),
251                });
252            }
253            _ => return Err(Error::InvalidXref),
254        }
255    }
256}
257
258/// Parses a cross-reference stream section (`/Type /XRef`) at `off`. The
259/// decoded data holds fixed-width big-endian fields laid out per `/W`; a
260/// zero-width type field defaults to type 1, `/Index` defaults to
261/// `[0 Size]`, and the stream's own dictionary is the section trailer.
262fn parse_stream_section(data: &[u8], off: usize) -> Result<XrefSectionInfo> {
263    let mut parser = Parser::at(data, off);
264    let (_, obj) = parser.parse_indirect(&NoResolve)?;
265    let end = parser.pos();
266    let stream = match obj {
267        Object::Stream(s) => s,
268        _ => return Err(Error::InvalidXref),
269    };
270    let decoded = decode_stream(&stream, &NoResolve).map_err(|_| Error::InvalidXref)?;
271    let dict = stream.dict;
272    let widths: Vec<usize> = dict
273        .get_array("W")
274        .ok_or(Error::InvalidXref)?
275        .iter()
276        .map(|v| {
277            v.as_int()
278                .filter(|&n| (0..=8).contains(&n))
279                .map(|n| n as usize)
280        })
281        .collect::<Option<Vec<_>>>()
282        .ok_or(Error::InvalidXref)?;
283    let w1 = widths.first().copied().unwrap_or(0);
284    let w2 = widths.get(1).copied().unwrap_or(0);
285    let w3 = widths.get(2).copied().unwrap_or(0);
286    let entry_len = w1 + w2 + w3;
287    if entry_len == 0 {
288        return Err(Error::InvalidXref);
289    }
290    let size = dict.get_int("Size").unwrap_or(0).max(0) as u64;
291    let subsections: Vec<(u64, u64)> = match dict.get_array("Index") {
292        Some(index) => index
293            .chunks(2)
294            .filter_map(|pair| {
295                let start = pair.first()?.as_int()?;
296                let count = pair.get(1)?.as_int()?;
297                (start >= 0 && count >= 0).then_some((start as u64, count as u64))
298            })
299            .collect(),
300        None => vec![(0, size)],
301    };
302    let mut section = Xref::default();
303    let mut pos = 0usize;
304    'subsections: for (start, count) in subsections {
305        for i in 0..count {
306            if pos + entry_len > decoded.len() {
307                break 'subsections; // lenient: truncated data ends the table
308            }
309            let kind = if w1 == 0 {
310                1
311            } else {
312                read_be(&decoded[pos..pos + w1])
313            };
314            let f2 = read_be(&decoded[pos + w1..pos + w1 + w2]);
315            let f3 = read_be(&decoded[pos + w1 + w2..pos + entry_len]);
316            pos += entry_len;
317            let entry = match kind {
318                1 => XrefEntry::InFile {
319                    offset: f2,
320                    gen: f3.min(65535) as u16,
321                },
322                2 => match (u32::try_from(f2), u32::try_from(f3)) {
323                    (Ok(stream_num), Ok(index)) => XrefEntry::InStream { stream_num, index },
324                    _ => XrefEntry::Free,
325                },
326                // Type 0 is free; unknown types read as references to the
327                // null object, which a free entry models exactly.
328                _ => XrefEntry::Free,
329            };
330            if let Ok(num) = u32::try_from(start + i) {
331                section.add(num, entry);
332            }
333        }
334    }
335    let prev = dict.get_int("Prev");
336    section.trailer = dict;
337    Ok(XrefSectionInfo {
338        xref: section,
339        kind: XrefKind::Stream,
340        prev,
341        xrefstm: None,
342        span: Span::new(off as u64, end as u64),
343        trailer_span: None,
344    })
345}
346
347/// Whole-file recovery: collects every `N G obj` header (the last
348/// occurrence of an object number wins), adopts the last parseable trailer
349/// dictionary (preferring one that names `/Root`), and failing that
350/// promotes the first `/Type /Catalog` object found to `/Root`.
351fn recovery_scan(data: &[u8]) -> Result<Xref> {
352    let mut xref = Xref::default();
353    for pos in memchr::memmem::find_iter(data, b"obj") {
354        if let Some((num, gen, start)) = obj_header_before(data, pos) {
355            // Direct insert: a later definition of the same object wins.
356            xref.map.insert(
357                num,
358                XrefEntry::InFile {
359                    offset: start as u64,
360                    gen,
361                },
362            );
363        }
364    }
365    if xref.map.is_empty() {
366        return Err(Error::InvalidXref);
367    }
368    let trailers: Vec<usize> = memchr::memmem::find_iter(data, b"trailer").collect();
369    for &tp in trailers.iter().rev() {
370        let mut parser = Parser::at(data, tp + b"trailer".len());
371        if let Ok(Object::Dict(dict)) = parser.parse_object(&NoResolve) {
372            let has_root = dict.get("Root").is_some();
373            if xref.trailer.is_empty() || has_root {
374                xref.trailer = dict;
375            }
376            if has_root {
377                break;
378            }
379        }
380    }
381    if xref.trailer.get("Root").is_none() {
382        if let Some(catalog) = find_catalog(data, &xref) {
383            xref.trailer
384                .insert(Name("Root".to_string()), Object::Ref(catalog));
385        }
386    }
387    if xref.trailer.get("Size").is_none() {
388        let size = xref.map.keys().max().map_or(0, |&m| i64::from(m) + 1);
389        xref.trailer
390            .insert(Name("Size".to_string()), Object::Int(size));
391    }
392    Ok(xref)
393}
394
395/// Parses recovered objects in ascending number order until one turns out
396/// to be a dictionary with `/Type /Catalog`; returns its reference.
397fn find_catalog(data: &[u8], xref: &Xref) -> Option<ObjRef> {
398    let mut nums: Vec<u32> = xref.map.keys().copied().collect();
399    nums.sort_unstable();
400    for num in nums {
401        let XrefEntry::InFile { offset, .. } = xref.map[&num] else {
402            continue;
403        };
404        let mut parser = Parser::at(data, offset as usize);
405        let Ok((r, obj)) = parser.parse_indirect(&NoResolve) else {
406            continue;
407        };
408        let is_catalog = obj
409            .as_dict()
410            .and_then(|d| d.get_name("Type"))
411            .is_some_and(|n| n.0 == "Catalog");
412        if is_catalog {
413            return Some(r);
414        }
415    }
416    None
417}
418
419/// If the `obj` keyword at byte `kw` terminates an `N G obj` header,
420/// returns `(N, G, header start offset)`. Both numbers must be plain digit
421/// runs at token boundaries, separated from each other and from `obj` by at
422/// least one whitespace byte, with values in range for `u32`/`u16`.
423fn obj_header_before(data: &[u8], kw: usize) -> Option<(u32, u16, usize)> {
424    if let Some(&after) = data.get(kw + 3) {
425        if !is_token_boundary(after) {
426            return None;
427        }
428    }
429    let gen_end = strip_ws_back(data, kw);
430    let gen_start = strip_digits_back(data, gen_end);
431    let num_end = strip_ws_back(data, gen_start);
432    let num_start = strip_digits_back(data, num_end);
433    if gen_end == kw || gen_start == gen_end || num_end == gen_start || num_start == num_end {
434        return None;
435    }
436    if num_start > 0 && !is_token_boundary(data[num_start - 1]) {
437        return None;
438    }
439    let gen: u16 = ascii_int(&data[gen_start..gen_end])?;
440    let num: u32 = ascii_int(&data[num_start..num_end])?;
441    Some((num, gen, num_start))
442}
443
444/// Steps `end` back over trailing PDF whitespace.
445fn strip_ws_back(data: &[u8], mut end: usize) -> usize {
446    while end > 0 && is_pdf_whitespace(data[end - 1]) {
447        end -= 1;
448    }
449    end
450}
451
452/// Steps `end` back over trailing ASCII digits.
453fn strip_digits_back(data: &[u8], mut end: usize) -> usize {
454    while end > 0 && data[end - 1].is_ascii_digit() {
455        end -= 1;
456    }
457    end
458}
459
460/// Parses a decimal integer from ASCII digits, rejecting overflow.
461fn ascii_int<T: std::str::FromStr>(bytes: &[u8]) -> Option<T> {
462    std::str::from_utf8(bytes).ok()?.parse().ok()
463}
464
465/// True for bytes that end a token: PDF whitespace or a delimiter.
466fn is_token_boundary(b: u8) -> bool {
467    is_pdf_whitespace(b)
468        || matches!(
469            b,
470            b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
471        )
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use pdfboss_testkit::{objstm_payload, simple_doc, PdfBuilder};
478
479    /// Offset of the first occurrence of `needle` in `data`.
480    fn pos_of(data: &[u8], needle: &[u8]) -> usize {
481        memchr::memmem::find(data, needle).unwrap()
482    }
483
484    /// Asserts that object `num` is an in-file entry pointing at its own
485    /// `num 0 obj` header.
486    fn assert_points_at_header(xref: &Xref, data: &[u8], num: u32) {
487        let header = format!("{num} 0 obj");
488        let expected = pos_of(data, header.as_bytes()) as u64;
489        assert_eq!(
490            xref.get(num),
491            Some(XrefEntry::InFile {
492                offset: expected,
493                gen: 0
494            }),
495            "entry for object {num}"
496        );
497    }
498
499    /// Overwrites the digits after the last `startxref` with nines so the
500    /// announced offset points nowhere useful.
501    fn corrupt_startxref(data: &mut [u8]) {
502        let mut i = memchr::memmem::rfind(data, b"startxref").unwrap() + b"startxref".len();
503        while !data[i].is_ascii_digit() {
504            i += 1;
505        }
506        while data[i].is_ascii_digit() {
507            data[i] = b'9';
508            i += 1;
509        }
510    }
511
512    #[test]
513    fn classic_table_loads_all_entries() {
514        let data = simple_doc("Hello");
515        let xref = load_xref(&data).unwrap();
516        assert_eq!(xref.get(0), Some(XrefEntry::Free));
517        for num in 1..=5 {
518            assert_points_at_header(&xref, &data, num);
519        }
520        assert_eq!(xref.get(6), None);
521        assert_eq!(xref.trailer.get_ref("Root").map(|r| r.num), Some(1));
522        assert_eq!(xref.trailer.get_int("Size"), Some(6));
523    }
524
525    #[test]
526    fn xref_stream_loads_infile_and_instream_entries() {
527        let (dict, payload) = objstm_payload(&[
528            (1, "<< /Type /Catalog /Pages 2 0 R >>"),
529            (2, "<< /Type /Pages /Kids [] /Count 0 >>"),
530            (5, "(text)"),
531        ]);
532        let mut b = PdfBuilder::new();
533        b.stream(6, &dict, &payload);
534        b.stream(4, "", b"BT ET");
535        let data = b.build_xref_stream(1);
536        let xref = load_xref(&data).unwrap();
537        assert_eq!(xref.get(0), Some(XrefEntry::Free));
538        for (num, index) in [(1, 0), (2, 1), (5, 2)] {
539            assert_eq!(
540                xref.get(num),
541                Some(XrefEntry::InStream {
542                    stream_num: 6,
543                    index
544                }),
545                "type-2 entry for object {num}"
546            );
547        }
548        for num in [4, 6, 7] {
549            assert_points_at_header(&xref, &data, num);
550        }
551        assert_eq!(xref.trailer.get_ref("Root").map(|r| r.num), Some(1));
552        assert_eq!(
553            xref.trailer.get_name("Type").map(|n| n.0.as_str()),
554            Some("XRef"),
555            "the stream's own dictionary is the trailer"
556        );
557    }
558
559    #[test]
560    fn recovery_scan_after_corrupt_startxref() {
561        let mut data = simple_doc("rescue me");
562        corrupt_startxref(&mut data);
563        let xref = load_xref(&data).unwrap();
564        for num in 1..=5 {
565            assert_points_at_header(&xref, &data, num);
566        }
567        assert_eq!(xref.trailer.get_ref("Root").map(|r| r.num), Some(1));
568    }
569
570    #[test]
571    fn recovery_finds_root_via_catalog_when_trailer_is_unreadable() {
572        let mut data = simple_doc("x");
573        corrupt_startxref(&mut data);
574        let tp = memchr::memmem::rfind(&data, b"trailer").unwrap();
575        data[tp..tp + b"trailer".len()].copy_from_slice(b"trai1er");
576        let xref = load_xref(&data).unwrap();
577        for num in 1..=5 {
578            assert_points_at_header(&xref, &data, num);
579        }
580        let root = xref.trailer.get_ref("Root").unwrap();
581        assert_eq!((root.num, root.gen), (1, 0), "object 1 is the catalog");
582        assert_eq!(xref.trailer.get_int("Size"), Some(6), "synthesized /Size");
583    }
584
585    #[test]
586    fn recovery_last_occurrence_of_an_object_wins() {
587        let mut data = simple_doc("x");
588        corrupt_startxref(&mut data);
589        let redefinition = data.len() as u64;
590        data.extend_from_slice(b"5 0 obj\n<< /Replaced true >>\nendobj\n");
591        let xref = load_xref(&data).unwrap();
592        assert_eq!(
593            xref.get(5),
594            Some(XrefEntry::InFile {
595                offset: redefinition,
596                gen: 0
597            })
598        );
599        for num in 1..=4 {
600            assert_points_at_header(&xref, &data, num);
601        }
602    }
603
604    #[test]
605    fn recovery_ignores_endobj_and_bad_headers() {
606        let data = b"garbage endobj more\n7 2 obj\n<< /Type /Catalog >>\nendobj\nxobj 9 9";
607        let xref = load_xref(data).unwrap();
608        let offset = pos_of(data, b"7 2 obj") as u64;
609        assert_eq!(xref.get(7), Some(XrefEntry::InFile { offset, gen: 2 }));
610        assert_eq!(xref.map.len(), 1, "only the real header is recovered");
611        let root = xref.trailer.get_ref("Root").unwrap();
612        assert_eq!((root.num, root.gen), (7, 2));
613    }
614
615    #[test]
616    fn unrecoverable_garbage_is_invalid_xref() {
617        assert!(matches!(load_xref(b"not a pdf"), Err(Error::InvalidXref)));
618        assert!(matches!(load_xref(b""), Err(Error::InvalidXref)));
619    }
620
621    #[test]
622    fn classic_table_with_19_and_21_byte_lines() {
623        let mut data = b"%PDF-1.4\n".to_vec();
624        let obj1 = data.len();
625        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
626        let obj2 = data.len();
627        data.extend_from_slice(b"2 0 obj\n(hi)\nendobj\n");
628        let xref_off = data.len();
629        data.extend_from_slice(b"xref\n0 3\n");
630        data.extend_from_slice(b"0000000000 65535 f\n"); // 19 bytes: bare LF
631        data.extend_from_slice(format!("{obj1:010} 00000 n\n").as_bytes()); // 19 bytes
632        data.extend_from_slice(format!("{obj2:010} 00000  n\r\n").as_bytes()); // 21 bytes
633        data.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
634        data.extend_from_slice(format!("startxref\n{xref_off}\n%%EOF\n").as_bytes());
635        let xref = load_xref(&data).unwrap();
636        // Entry 0 proves the table itself was read (recovery never adds Free).
637        assert_eq!(xref.get(0), Some(XrefEntry::Free));
638        for (num, offset) in [(1, obj1), (2, obj2)] {
639            assert_eq!(
640                xref.get(num),
641                Some(XrefEntry::InFile {
642                    offset: offset as u64,
643                    gen: 0
644                })
645            );
646        }
647        assert_eq!(xref.trailer.get_ref("Root").map(|r| r.num), Some(1));
648    }
649
650    #[test]
651    fn xref_stream_prev_chains_to_classic_section() {
652        let mut data = b"%PDF-1.5\n".to_vec();
653        let obj1 = data.len();
654        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
655        let obj2_old = data.len();
656        data.extend_from_slice(b"2 0 obj\n(old)\nendobj\n");
657        let classic_off = data.len();
658        data.extend_from_slice(b"xref\n0 3\n0000000000 65535 f\r\n");
659        data.extend_from_slice(format!("{obj1:010} 00000 n\r\n").as_bytes());
660        data.extend_from_slice(format!("{obj2_old:010} 00000 n\r\n").as_bytes());
661        data.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
662        // Incremental update: object 2 replaced, object 3 added.
663        let obj2_new = data.len();
664        data.extend_from_slice(b"2 0 obj\n(new)\nendobj\n");
665        let obj3 = data.len();
666        data.extend_from_slice(b"3 0 obj\n42\nendobj\n");
667        let stream_off = data.len();
668        let mut fields = Vec::new();
669        for offset in [obj2_new, obj3, stream_off] {
670            fields.push(1u8);
671            fields.extend_from_slice(&(offset as u32).to_be_bytes());
672            fields.extend_from_slice(&0u16.to_be_bytes());
673        }
674        data.extend_from_slice(
675            format!(
676                "4 0 obj\n<< /Type /XRef /Size 5 /W [1 4 2] /Index [2 3] \
677                 /Prev {} /Root 1 0 R /Length {} >>\nstream\n",
678                classic_off,
679                fields.len()
680            )
681            .as_bytes(),
682        );
683        data.extend_from_slice(&fields);
684        data.extend_from_slice(b"\nendstream\nendobj\n");
685        data.extend_from_slice(format!("startxref\n{stream_off}\n%%EOF\n").as_bytes());
686
687        let xref = load_xref(&data).unwrap();
688        assert_eq!(xref.get(0), Some(XrefEntry::Free));
689        let infile = |offset: usize| {
690            Some(XrefEntry::InFile {
691                offset: offset as u64,
692                gen: 0,
693            })
694        };
695        assert_eq!(xref.get(1), infile(obj1), "only the classic section has 1");
696        assert_eq!(xref.get(2), infile(obj2_new), "newest section wins");
697        assert_eq!(xref.get(3), infile(obj3));
698        assert_eq!(xref.get(4), infile(stream_off));
699        assert_eq!(xref.trailer.get_int("Size"), Some(5), "newest trailer wins");
700        assert_eq!(xref.trailer.get_ref("Root").map(|r| r.num), Some(1));
701    }
702
703    #[test]
704    fn hybrid_xrefstm_overrides_entries_the_table_marks_free() {
705        let mut data = b"%PDF-1.5\n".to_vec();
706        let obj1 = data.len();
707        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
708        let obj2 = data.len();
709        data.extend_from_slice(b"2 0 obj\n(hidden)\nendobj\n");
710        let stm_off = data.len();
711        let mut fields = Vec::new();
712        for offset in [obj2, stm_off] {
713            fields.push(1u8);
714            fields.extend_from_slice(&(offset as u32).to_be_bytes());
715            fields.extend_from_slice(&0u16.to_be_bytes());
716        }
717        data.extend_from_slice(
718            format!(
719                "3 0 obj\n<< /Type /XRef /Size 4 /W [1 4 2] /Index [2 1 3 1] \
720                 /Root 1 0 R /Length {} >>\nstream\n",
721                fields.len()
722            )
723            .as_bytes(),
724        );
725        data.extend_from_slice(&fields);
726        data.extend_from_slice(b"\nendstream\nendobj\n");
727        let classic_off = data.len();
728        data.extend_from_slice(b"xref\n0 3\n0000000000 65535 f\r\n");
729        data.extend_from_slice(format!("{obj1:010} 00000 n\r\n").as_bytes());
730        data.extend_from_slice(b"0000000000 00001 f\r\n"); // object 2 hidden
731        data.extend_from_slice(
732            format!("trailer\n<< /Size 4 /Root 1 0 R /XRefStm {stm_off} >>\n").as_bytes(),
733        );
734        data.extend_from_slice(format!("startxref\n{classic_off}\n%%EOF\n").as_bytes());
735
736        let xref = load_xref(&data).unwrap();
737        assert_eq!(
738            xref.get(2),
739            Some(XrefEntry::InFile {
740                offset: obj2 as u64,
741                gen: 0
742            }),
743            "the hybrid stream entry beats the table's free entry"
744        );
745        assert_eq!(
746            xref.get(3),
747            Some(XrefEntry::InFile {
748                offset: stm_off as u64,
749                gen: 0
750            })
751        );
752        assert_eq!(xref.trailer.get_ref("Root").map(|r| r.num), Some(1));
753    }
754
755    #[test]
756    fn prev_loop_is_broken_by_the_visited_guard() {
757        let mut data = b"%PDF-1.4\n".to_vec();
758        let obj1 = data.len();
759        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
760        let xref_off = data.len();
761        data.extend_from_slice(b"xref\n0 2\n0000000000 65535 f\r\n");
762        data.extend_from_slice(format!("{obj1:010} 00000 n\r\n").as_bytes());
763        data.extend_from_slice(
764            format!("trailer\n<< /Size 2 /Root 1 0 R /Prev {xref_off} >>\n").as_bytes(),
765        );
766        data.extend_from_slice(format!("startxref\n{xref_off}\n%%EOF\n").as_bytes());
767        let xref = load_xref(&data).unwrap();
768        assert_eq!(
769            xref.get(1),
770            Some(XrefEntry::InFile {
771                offset: obj1 as u64,
772                gen: 0
773            })
774        );
775    }
776
777    #[test]
778    fn startxref_found_beyond_the_last_1_kib() {
779        let mut data = simple_doc("padded");
780        // A decoy header: only the recovery scan would pick this up.
781        data.extend_from_slice(b"999 0 obj\n<< >>\nendobj\n");
782        data.extend_from_slice(&vec![b' '; 2048]);
783        let xref = load_xref(&data).unwrap();
784        assert_eq!(xref.get(999), None, "chain path used, not recovery");
785        for num in 1..=5 {
786            assert_points_at_header(&xref, &data, num);
787        }
788    }
789
790    #[test]
791    fn merge_keeps_first_seen_entries_and_trailer_keys() {
792        let mut newer = Xref::default();
793        newer.map.insert(1, XrefEntry::Free);
794        newer
795            .map
796            .insert(2, XrefEntry::InFile { offset: 20, gen: 0 });
797        newer
798            .trailer
799            .insert(Name("Size".to_string()), Object::Int(3));
800        let mut older = Xref::default();
801        older
802            .map
803            .insert(1, XrefEntry::InFile { offset: 10, gen: 0 });
804        older
805            .map
806            .insert(3, XrefEntry::InFile { offset: 30, gen: 1 });
807        older
808            .trailer
809            .insert(Name("Size".to_string()), Object::Int(9));
810        older
811            .trailer
812            .insert(Name("Info".to_string()), Object::Int(7));
813        newer.merge(older);
814        assert_eq!(newer.get(1), Some(XrefEntry::Free), "deletion is kept");
815        assert_eq!(newer.get(2), Some(XrefEntry::InFile { offset: 20, gen: 0 }));
816        assert_eq!(newer.get(3), Some(XrefEntry::InFile { offset: 30, gen: 1 }));
817        assert_eq!(newer.trailer.get_int("Size"), Some(3));
818        assert_eq!(newer.trailer.get_int("Info"), Some(7));
819    }
820
821    #[test]
822    fn iter_reports_every_entry() {
823        let data = pdfboss_testkit::simple_doc("iter");
824        let xref = load_xref(&data).unwrap();
825        let mut nums: Vec<u32> = xref.iter().map(|pair| pair.0).collect();
826        nums.sort_unstable();
827        assert_eq!(nums.len(), xref.len());
828        assert!(!xref.is_empty());
829        for (num, entry) in xref.iter() {
830            assert_eq!(xref.get(num), Some(entry));
831        }
832    }
833
834    #[test]
835    fn parse_section_at_classic_reports_spans() {
836        let data = pdfboss_testkit::simple_doc("spans");
837        // `rfind` would match inside `startxref` (which ends in "xref" and
838        // follows the real section); `find` finds the actual keyword.
839        let off = memchr::memmem::find(&data, b"xref").unwrap();
840        let info = parse_section_at(&data, off).unwrap();
841        assert_eq!(info.kind, crate::elements::XrefKind::Table);
842        assert!(info.prev.is_none());
843        assert!(info.xrefstm.is_none());
844        assert!(!info.xref.is_empty());
845        // The section span starts at `xref` and ends where the trailer begins.
846        assert_eq!(info.span.start, off as u64);
847        let tspan = info.trailer_span.expect("classic sections have a trailer");
848        assert_eq!(info.span.end, tspan.start);
849        assert!(data[tspan.start as usize..].starts_with(b"trailer"));
850        // Re-parsing the trailer region yields the same dictionary.
851        let mut parser = Parser::at(&data, tspan.start as usize + b"trailer".len());
852        let reparsed = parser.parse_object(&NoResolve).unwrap();
853        assert_eq!(
854            reparsed.as_dict().unwrap().get("Root"),
855            info.xref.trailer.get("Root")
856        );
857        assert!(tspan.end as usize <= data.len());
858    }
859
860    #[test]
861    fn parse_section_at_stream_reports_spans() {
862        let mut builder = pdfboss_testkit::PdfBuilder::new();
863        builder.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
864        builder.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
865        builder.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>");
866        let data = builder.build_xref_stream(1);
867        let startxref = memchr::memmem::rfind(&data, b"startxref").unwrap();
868        let mut lexer = Lexer::at(&data, startxref + b"startxref".len());
869        let off = match lexer.next_token().unwrap() {
870            Token::Int(v) => v as usize,
871            other => panic!("expected startxref offset, got {other:?}"),
872        };
873        let info = parse_section_at(&data, off).unwrap();
874        assert_eq!(info.kind, crate::elements::XrefKind::Stream);
875        assert!(info.trailer_span.is_none());
876        assert_eq!(info.span.start, off as u64);
877        assert!(info.span.end > info.span.start && info.span.end as u64 <= data.len() as u64);
878        // The span covers the whole stream object.
879        let body = &data[info.span.start as usize..info.span.end as usize];
880        assert!(memchr::memmem::find(body, b"endstream").is_some());
881    }
882}