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