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