Skip to main content

stet_pdf_reader/
xref.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF cross-reference table and trailer parsing.
6
7use crate::error::PdfError;
8use crate::filters;
9use crate::lexer::{Lexer, Token, parse_dict_body};
10use crate::objects::PdfDict;
11
12/// Location of an object in the PDF file.
13#[derive(Debug, Clone, Copy)]
14pub enum XrefEntry {
15    /// Object at byte offset, with generation number.
16    InFile { offset: usize, generation: u16 },
17    /// Object compressed inside an object stream.
18    InStream {
19        stream_obj_num: u32,
20        index_within: u16,
21    },
22    /// Free entry.
23    Free,
24}
25
26/// Parsed cross-reference data + trailer.
27#[derive(Clone)]
28pub struct XrefTable {
29    /// Map from object number to entry. Index = object number.
30    entries: Vec<Option<XrefEntry>>,
31    /// Trailer dictionary (from the most recent trailer/xref stream).
32    pub trailer: PdfDict,
33}
34
35impl XrefTable {
36    /// Create an empty xref table (for tests).
37    #[cfg(test)]
38    pub(crate) fn empty() -> Self {
39        Self {
40            entries: Vec::new(),
41            trailer: PdfDict::new(),
42        }
43    }
44
45    /// Look up an object's location by number.
46    pub fn get(&self, obj_num: u32) -> Option<&XrefEntry> {
47        self.entries.get(obj_num as usize).and_then(|e| e.as_ref())
48    }
49
50    /// Iterate over all entries (including None gaps).
51    pub fn entries(&self) -> &[Option<XrefEntry>] {
52        &self.entries
53    }
54
55    /// Total number of entry slots (including None gaps).
56    pub fn len(&self) -> usize {
57        self.entries.len()
58    }
59
60    /// Whether the table is empty.
61    pub fn is_empty(&self) -> bool {
62        self.entries.is_empty()
63    }
64}
65
66/// Parse the complete xref structure from a PDF file.
67pub fn parse_xref(data: &[u8]) -> Result<XrefTable, PdfError> {
68    let startxref = match find_startxref(data) {
69        Ok(v) => v,
70        Err(PdfError::NoStartXref) => return rebuild_xref_from_scan(data),
71        Err(e) => return Err(e),
72    };
73
74    // If %PDF- header isn't at byte 0 (e.g., UTF-8 BOM or prepended garbage),
75    // all internal offsets (startxref, /Prev) need adjustment. Compute this
76    // before following the xref chain so /Prev pointers are corrected too.
77    let header_offset = data.windows(5).position(|w| w == b"%PDF-").unwrap_or(0);
78
79    // Detect concatenated PDFs (multiple %PDF- headers). When present, the
80    // final section's offsets may be relative to a later header, not the first.
81    let pdf_headers: Vec<usize> = find_all_pdf_headers(data);
82
83    // Follow the /Prev chain, collecting (entries, trailer, file_offset).
84    // The file_offset is used to sort sections chronologically for merging.
85    let mut sections: Vec<(Vec<(u32, XrefEntry)>, PdfDict, usize)> = Vec::new();
86    let mut offset = startxref + header_offset;
87    let mut visited = std::collections::HashSet::new();
88
89    let mut xref_failed = false;
90
91    // If startxref + first header doesn't work, try with other headers
92    // (concatenated PDFs have offsets relative to a later %PDF- header).
93    if offset < data.len() && parse_xref_section(data, offset).is_err() && pdf_headers.len() > 1 {
94        for &h in pdf_headers.iter().skip(1).rev() {
95            let try_offset = startxref + h;
96            if try_offset < data.len() {
97                if let Ok((mut entries, trailer)) = parse_xref_section(data, try_offset) {
98                    // Adjust entry offsets: they're relative to this header
99                    shift_xref_entries(&mut entries, h);
100                    visited.insert(try_offset);
101                    let prev = trailer.get_int(b"Prev").map(|v| v as usize + h);
102                    sections.push((entries, trailer, try_offset));
103                    if let Some(p) = prev {
104                        offset = p;
105                    } else {
106                        xref_failed = false;
107                    }
108                    break;
109                }
110            }
111        }
112    }
113
114    loop {
115        if !visited.insert(offset) {
116            break; // Circular /Prev chain
117        }
118
119        match parse_xref_section(data, offset) {
120            Ok((entries, trailer)) => {
121                let prev = trailer.get_int(b"Prev").map(|v| v as usize + header_offset);
122                sections.push((entries, trailer, offset));
123                match prev {
124                    Some(p) => offset = p,
125                    None => break,
126                }
127            }
128            Err(_) => {
129                xref_failed = true;
130                break;
131            }
132        }
133    }
134
135    // If xref parsing failed completely, try discovering orphaned xref sections
136    // (e.g., linearized PDFs where startxref points to byte 0 but the real xref
137    // table is elsewhere in the file). Only fall back to full scan if that also
138    // finds nothing.
139    if xref_failed && sections.is_empty() {
140        discover_orphaned_xref_sections(
141            data,
142            &mut sections,
143            &mut visited,
144            header_offset,
145            &pdf_headers,
146        );
147        if sections.is_empty() {
148            return rebuild_xref_from_scan(data);
149        }
150    }
151
152    // Scan all trailer dicts and startxref values in the file for xref
153    // sections not reached by the /Prev chain. This handles PDFs with broken
154    // incremental updates where the chain dead-ends (missing trailer, circular
155    // /Prev, or self-referencing /Prev) before reaching the original xref.
156    discover_orphaned_xref_sections(
157        data,
158        &mut sections,
159        &mut visited,
160        header_offset,
161        &pdf_headers,
162    );
163
164    // Build the combined table: oldest entries first, newest override.
165    // Sort by file offset (ascending) — sections at higher offsets are newer
166    // revisions and should override earlier ones. This is more robust than
167    // assuming discovery order matches chronological order, which breaks when
168    // discover_orphaned_xref_sections finds sections in arbitrary order.
169    sections.sort_by_key(|(_, _, file_offset)| *file_offset);
170    let mut combined_entries: Vec<Option<XrefEntry>> = Vec::new();
171    let mut final_trailer = PdfDict::new();
172
173    for (entries, trailer, _) in sections {
174        for (num, entry) in entries {
175            let idx = num as usize;
176            if idx >= combined_entries.len() {
177                combined_entries.resize(idx + 1, None);
178            }
179            combined_entries[idx] = Some(entry);
180        }
181        // Merge trailer keys — later sections override earlier ones, but
182        // empty trailers (e.g., from truncated xref sections in truncated
183        // linearized PDFs) won't erase keys like /Root from earlier trailers
184        for (key, val) in trailer.into_entries() {
185            final_trailer.insert(key, val);
186        }
187    }
188
189    // If the assembled trailer is missing /Root (e.g., corrupted xref where
190    // the parser bailed out before reaching the trailer keyword), fall back to
191    // a full object scan which can recover /Root from the catalog object.
192    if final_trailer.get(b"Root").is_none() {
193        return rebuild_xref_from_scan(data);
194    }
195
196    // If %PDF- header isn't at byte 0 (e.g., UTF-8 BOM or prepended garbage),
197    // adjust all InFile xref offsets by the header position.
198    if header_offset > 0 {
199        for entry in combined_entries.iter_mut().flatten() {
200            if let XrefEntry::InFile { offset, .. } = entry {
201                *offset += header_offset;
202            }
203        }
204    }
205
206    // Supplement with a scan for objects not in any xref section.
207    // Handles linearized PDFs with objects appended after %%EOF
208    // or incremental updates without proper xref entries.
209    supplement_xref_from_scan(data, &mut combined_entries);
210
211    Ok(XrefTable {
212        entries: combined_entries,
213        trailer: final_trailer,
214    })
215}
216
217/// Scan all trailer dictionaries and startxref values in the file for xref
218/// sections not yet visited. Handles broken incremental updates where the
219/// /Prev chain dead-ends before reaching the original xref.
220fn discover_orphaned_xref_sections(
221    data: &[u8],
222    sections: &mut Vec<(Vec<(u32, XrefEntry)>, PdfDict, usize)>,
223    visited: &mut std::collections::HashSet<usize>,
224    header_offset: usize,
225    pdf_headers: &[usize],
226) {
227    let mut candidates = Vec::new();
228
229    // Collect /Prev offsets from all trailers in the file
230    let mut pos = 0;
231    while pos + 7 < data.len() {
232        if &data[pos..pos + 7] == b"trailer" {
233            let end = (pos + 500).min(data.len());
234            if let Some(prev_off) = find_int_after_key(&data[pos..end], b"/Prev") {
235                // /Prev values are PDF-internal offsets, need BOM adjustment
236                candidates.push(prev_off + header_offset);
237            }
238        }
239        // Also collect startxref values (point to xref sections from older revisions)
240        if pos + 9 < data.len() && &data[pos..pos + 9] == b"startxref" {
241            let end = (pos + 50).min(data.len());
242            if let Some(sx_off) = find_int_after_key(&data[pos..end], b"startxref") {
243                if sx_off > 0 {
244                    // startxref values are PDF-internal offsets, need BOM adjustment.
245                    // For concatenated PDFs, also try each %PDF header as base.
246                    candidates.push(sx_off + header_offset);
247                    for &h in pdf_headers.iter().skip(1) {
248                        let adjusted = sx_off + h;
249                        if adjusted < data.len() {
250                            candidates.push(adjusted);
251                        }
252                    }
253                }
254            }
255        }
256        // Also collect xref keyword positions directly — handles linearized PDFs
257        // where the first-page xref section is never reached via /Prev chain
258        if pos + 4 <= data.len() && &data[pos..pos + 4] == b"xref" {
259            // Make sure this isn't "startxref"
260            if pos == 0 || data[pos - 1] != b't' {
261                // This is already an actual file position, no adjustment needed
262                candidates.push(pos);
263            }
264        }
265        pos += 1;
266    }
267
268    // Try each candidate xref offset we haven't visited yet
269    for candidate in candidates {
270        let mut offset = candidate;
271        loop {
272            if !visited.insert(offset) {
273                break;
274            }
275            match parse_xref_section(data, offset) {
276                Ok((mut entries, trailer)) => {
277                    // If this xref section is under a secondary %PDF header,
278                    // its offsets are relative to that header — shift them.
279                    let section_header = owning_pdf_header(offset, pdf_headers);
280                    if section_header > 0 {
281                        shift_xref_entries(&mut entries, section_header);
282                    }
283                    let prev = trailer.get_int(b"Prev").map(|v| v as usize + header_offset);
284                    sections.push((entries, trailer, offset));
285                    match prev {
286                        Some(p) => offset = p,
287                        None => break,
288                    }
289                }
290                Err(_) => break,
291            }
292        }
293    }
294}
295
296/// Scan file for `N G obj` patterns and add entries for objects not already
297/// in the xref table. This catches objects appended after %%EOF in
298/// linearized PDFs or broken incremental updates.
299fn supplement_xref_from_scan(data: &[u8], entries: &mut Vec<Option<XrefEntry>>) {
300    let mut pos = 0;
301    while pos < data.len() {
302        // Advance to start-of-line
303        if pos > 0 && data[pos - 1] != b'\n' && data[pos - 1] != b'\r' {
304            while pos < data.len() && data[pos] != b'\n' && data[pos] != b'\r' {
305                pos += 1;
306            }
307            if pos < data.len() {
308                if data[pos] == b'\r' && pos + 1 < data.len() && data[pos + 1] == b'\n' {
309                    pos += 2;
310                } else {
311                    pos += 1;
312                }
313            }
314            continue;
315        }
316
317        if pos < data.len()
318            && data[pos].is_ascii_digit()
319            && let Some((obj_num, generation, obj_offset)) = try_parse_obj_header(data, pos)
320        {
321            let idx = obj_num as usize;
322            if idx < 100_000 {
323                // Add if not already in the table, or if the existing entry is
324                // Free (malformed xref tables sometimes mark real objects as
325                // free, e.g. when the subsection start number is off-by-one).
326                let dominated_by_existing = entries.get(idx).is_some_and(|e| {
327                    matches!(
328                        e,
329                        Some(XrefEntry::InFile { .. } | XrefEntry::InStream { .. })
330                    )
331                });
332                if !dominated_by_existing {
333                    if idx >= entries.len() {
334                        entries.resize(idx + 1, None);
335                    }
336                    entries[idx] = Some(XrefEntry::InFile {
337                        offset: obj_offset,
338                        generation,
339                    });
340                }
341            }
342        }
343
344        while pos < data.len() && data[pos] != b'\n' && data[pos] != b'\r' {
345            pos += 1;
346        }
347        if pos < data.len() {
348            if data[pos] == b'\r' && pos + 1 < data.len() && data[pos + 1] == b'\n' {
349                pos += 2;
350            } else {
351                pos += 1;
352            }
353        }
354    }
355}
356
357/// Find an integer value after a keyword in a byte region.
358/// Works for both `/Prev 12345` and `startxref\n12345`.
359fn find_int_after_key(data: &[u8], key: &[u8]) -> Option<usize> {
360    let s = std::str::from_utf8(data).ok()?;
361    let key_str = std::str::from_utf8(key).ok()?;
362    let idx = s.find(key_str)?;
363    let after = &s[idx + key_str.len()..];
364    let after = after.trim_start();
365    let end = after
366        .find(|c: char| !c.is_ascii_digit())
367        .unwrap_or(after.len());
368    if end == 0 {
369        return None;
370    }
371    after[..end].parse().ok()
372}
373
374/// Rebuild the xref table by scanning the entire file for `N G obj` patterns.
375/// Used as a fallback when the xref table/stream is damaged or missing.
376fn rebuild_xref_from_scan(data: &[u8]) -> Result<XrefTable, PdfError> {
377    let mut entries: Vec<Option<XrefEntry>> = Vec::new();
378
379    // Scan for "N G obj" patterns at the start of lines
380    let mut pos = 0;
381    while pos < data.len() {
382        // Skip to something that looks like a digit at start-of-line or start-of-file
383        if pos > 0 && data[pos - 1] != b'\n' && data[pos - 1] != b'\r' {
384            // Advance to next line (handle both \n and \r line endings)
385            while pos < data.len() && data[pos] != b'\n' && data[pos] != b'\r' {
386                pos += 1;
387            }
388            // Skip line ending character(s)
389            if pos < data.len() {
390                if data[pos] == b'\r' && pos + 1 < data.len() && data[pos + 1] == b'\n' {
391                    pos += 2;
392                } else {
393                    pos += 1;
394                }
395            }
396            continue;
397        }
398
399        // Try to parse: <obj_num> <gen_num> obj
400        if pos < data.len()
401            && data[pos].is_ascii_digit()
402            && let Some((obj_num, generation, obj_offset)) = try_parse_obj_header(data, pos)
403        {
404            let idx = obj_num as usize;
405            if idx < 100_000 {
406                // sanity limit
407                if idx >= entries.len() {
408                    entries.resize(idx + 1, None);
409                }
410                entries[idx] = Some(XrefEntry::InFile {
411                    offset: obj_offset,
412                    generation,
413                });
414            }
415        }
416
417        // Advance to next line (handle both \n and \r line endings)
418        while pos < data.len() && data[pos] != b'\n' && data[pos] != b'\r' {
419            pos += 1;
420        }
421        if pos < data.len() {
422            if data[pos] == b'\r' && pos + 1 < data.len() && data[pos + 1] == b'\n' {
423                pos += 2;
424            } else {
425                pos += 1;
426            }
427        }
428    }
429
430    // Try to parse any xref stream objects found during the scan.
431    // These carry the trailer dict (with /Root) and may reference objects
432    // inside object streams that aren't discoverable by byte-scanning alone.
433    for entry in entries.iter().flatten() {
434        if let XrefEntry::InFile { offset, .. } = entry {
435            let search_end = (*offset + 512).min(data.len());
436            let slice = &data[*offset..search_end];
437            if slice.windows(10).any(|w| w == b"/Type/XRef")
438                || slice.windows(11).any(|w| w == b"/Type /XRef")
439            {
440                if let Ok((xref_entries, xref_trailer)) = parse_xref_stream(data, *offset) {
441                    // Merge entries from xref stream (includes ObjStm refs)
442                    let mut merged = entries;
443                    for (num, xentry) in xref_entries {
444                        let idx = num as usize;
445                        if idx < 100_000 {
446                            if idx >= merged.len() {
447                                merged.resize(idx + 1, None);
448                            }
449                            if merged[idx].is_none() {
450                                merged[idx] = Some(xentry);
451                            }
452                        }
453                    }
454                    return Ok(XrefTable {
455                        entries: merged,
456                        trailer: xref_trailer,
457                    });
458                }
459            }
460        }
461    }
462
463    // Parse the trailer dict (search for "trailer" keyword)
464    let trailer = find_trailer_dict(data).unwrap_or_default();
465
466    // If no trailer has /Root, scan objects for /Type /Catalog
467    let trailer = if trailer.get(b"Root").is_none() {
468        let mut t = trailer;
469        if let Some(root_num) = find_catalog_obj(data, &entries) {
470            t.insert(b"Root".to_vec(), crate::objects::PdfObj::Ref(root_num, 0));
471        }
472        t
473    } else {
474        trailer
475    };
476
477    Ok(XrefTable { entries, trailer })
478}
479
480/// Try to parse an object header "N G obj" at the given position.
481/// Returns (obj_num, generation, offset) if successful.
482fn try_parse_obj_header(data: &[u8], pos: usize) -> Option<(u32, u16, usize)> {
483    let mut p = pos;
484
485    // Parse object number
486    let num_start = p;
487    while p < data.len() && data[p].is_ascii_digit() {
488        p += 1;
489    }
490    if p == num_start || p >= data.len() {
491        return None;
492    }
493    let obj_num: u32 = std::str::from_utf8(&data[num_start..p])
494        .ok()?
495        .parse()
496        .ok()?;
497
498    // Skip spaces
499    while p < data.len() && data[p] == b' ' {
500        p += 1;
501    }
502
503    // Parse generation number
504    let gen_start = p;
505    while p < data.len() && data[p].is_ascii_digit() {
506        p += 1;
507    }
508    if p == gen_start || p >= data.len() {
509        return None;
510    }
511    let generation: u16 = std::str::from_utf8(&data[gen_start..p])
512        .ok()?
513        .parse()
514        .ok()?;
515
516    // Skip spaces
517    while p < data.len() && data[p] == b' ' {
518        p += 1;
519    }
520
521    // Check for "obj" keyword
522    if p + 3 > data.len() || &data[p..p + 3] != b"obj" {
523        return None;
524    }
525    // "obj" must be followed by whitespace or a PDF delimiter (not part of a longer word)
526    let after_obj = p + 3;
527    if after_obj < data.len()
528        && !is_whitespace(data[after_obj])
529        && !matches!(data[after_obj], b'<' | b'[' | b'(' | b'/')
530    {
531        return None;
532    }
533
534    Some((obj_num, generation, pos))
535}
536
537/// Search for a `trailer << ... >>` dict in the file.
538fn find_trailer_dict(data: &[u8]) -> Option<PdfDict> {
539    // Search backwards from end for "trailer"
540    let needle = b"trailer";
541    let mut pos = data.len().saturating_sub(needle.len());
542    while pos > 0 {
543        if &data[pos..pos + needle.len()] == needle {
544            let dict_start = pos + needle.len();
545            let mut lexer = Lexer::at(data, dict_start);
546            if let Ok(Token::DictBegin) = lexer.next_token()
547                && let Ok(dict) = parse_dict_body(&mut lexer)
548            {
549                return Some(dict);
550            }
551        }
552        pos -= 1;
553    }
554    None
555}
556
557/// Scan resolved objects for one with /Type /Catalog and return its object number.
558fn find_catalog_obj(data: &[u8], entries: &[Option<XrefEntry>]) -> Option<u32> {
559    for (num, entry) in entries.iter().enumerate() {
560        if let Some(XrefEntry::InFile { offset, .. }) = entry {
561            // Quick byte-level check before full parsing
562            let search_end = (*offset + 512).min(data.len());
563            let slice = &data[*offset..search_end];
564            if let Some(idx) = slice.windows(8).position(|w| w == b"/Catalog") {
565                // Verify /Type precedes it
566                if idx > 5 && slice[..idx].windows(5).any(|w| w == b"/Type") {
567                    return Some(num as u32);
568                }
569            }
570            // Also check xref stream objects which carry /Root in their dict
571            // (the catalog itself may be inside an object stream, unreachable
572            // by byte-scanning, but the xref stream dict has `/Root N 0 R`).
573            if slice.windows(5).any(|w| w == b"/Root")
574                && slice.windows(10).any(|w| w == b"/Type/XRef")
575            {
576                // Extract the /Root reference: parse the dict to find it
577                let obj_start = slice
578                    .windows(3)
579                    .position(|w| w == b"obj")
580                    .map(|p| p + 3)
581                    .unwrap_or(0);
582                let mut lexer = Lexer::at(slice, obj_start);
583                if let Ok(Token::DictBegin) = lexer.next_token()
584                    && let Ok(dict) = parse_dict_body(&mut lexer)
585                {
586                    if let Some((root_num, _)) = dict.get_ref(b"Root") {
587                        return Some(root_num);
588                    }
589                }
590            }
591        }
592    }
593    None
594}
595
596/// Parse one xref section (classic table or xref stream) at the given offset.
597fn parse_xref_section(
598    data: &[u8],
599    offset: usize,
600) -> Result<(Vec<(u32, XrefEntry)>, PdfDict), PdfError> {
601    // Peek at the data to determine if this is a classic xref or xref stream.
602    // Some PDF generators have startxref offsets that are slightly off,
603    // so scan forward (skipping whitespace) and backward to find "xref".
604    let mut pos = offset;
605    while pos < data.len() && is_whitespace(data[pos]) {
606        pos += 1;
607    }
608
609    if pos + 4 <= data.len() && &data[pos..pos + 4] == b"xref" {
610        parse_classic_xref(data, pos)
611    } else {
612        // Scan backward up to 20 bytes for "xref" (handles off-by-N offsets)
613        let scan_start = offset.saturating_sub(20);
614        let found = (scan_start..offset)
615            .rev()
616            .find(|&off| off + 4 <= data.len() && &data[off..off + 4] == b"xref");
617        if let Some(xref_pos) = found {
618            parse_classic_xref(data, xref_pos)
619        } else {
620            // Xref stream: an indirect object with /Type /XRef
621            match parse_xref_stream(data, offset) {
622                Ok(result) => Ok(result),
623                Err(_) => {
624                    // /Prev offset may point into the middle of an xref stream
625                    // object (common in linearized PDFs). Search backwards for
626                    // the actual object header.
627                    let scan_start = offset.saturating_sub(256);
628                    let scan_end = offset.min(data.len());
629                    for search in (scan_start..scan_end).rev() {
630                        if data[search].is_ascii_digit() {
631                            if let Some((_, _, obj_offset)) = try_parse_obj_header(data, search) {
632                                if obj_offset == search {
633                                    if let Ok(result) = parse_xref_stream(data, search) {
634                                        return Ok(result);
635                                    }
636                                }
637                            }
638                        }
639                    }
640                    parse_xref_stream(data, offset) // return original error
641                }
642            }
643        }
644    }
645}
646
647/// Parse a classic xref table starting at `offset` (pointing to the `xref` keyword).
648fn parse_classic_xref(
649    data: &[u8],
650    offset: usize,
651) -> Result<(Vec<(u32, XrefEntry)>, PdfDict), PdfError> {
652    let mut pos = offset + 4; // skip "xref"
653
654    // Skip whitespace after "xref"
655    while pos < data.len() && is_whitespace(data[pos]) {
656        pos += 1;
657    }
658
659    let mut entries = Vec::new();
660
661    // Parse subsections until we hit "trailer" (or data that isn't a valid
662    // subsection header — some broken PDFs omit the trailer entirely).
663    let mut has_trailer = false;
664    loop {
665        // Check for "trailer" keyword
666        if pos + 7 <= data.len() && &data[pos..pos + 7] == b"trailer" {
667            pos += 7;
668            has_trailer = true;
669            break;
670        }
671
672        // Parse subsection header: <first_obj_num> <count>
673        // If this fails, the entries may have ended without a trailer.
674        let Ok((first_obj, new_pos)) = parse_int_at(data, pos) else {
675            break;
676        };
677        pos = new_pos;
678        while pos < data.len() && (data[pos] == b' ' || data[pos] == b'\t') {
679            pos += 1;
680        }
681        let Ok((count, new_pos)) = parse_int_at(data, pos) else {
682            break;
683        };
684        pos = new_pos;
685
686        // Skip to start of entries
687        while pos < data.len() && is_whitespace(data[pos]) {
688            pos += 1;
689        }
690
691        // Parse entries: spec says 20 bytes each, but tolerate 21 (extra space before EOL).
692        // If an entry is malformed (e.g. we've run into binary stream data after
693        // a trailer-less xref), stop parsing gracefully with what we have.
694        let mut entry_error = false;
695        for i in 0..count as u32 {
696            if pos + 18 > data.len() {
697                break;
698            }
699
700            // Parse: OOOOOOOOOO GGGGG f/n + EOL (variable length)
701            let Ok(off_str) = std::str::from_utf8(&data[pos..pos + 10]) else {
702                entry_error = true;
703                break;
704            };
705            let Ok(off) = off_str.trim().parse::<usize>() else {
706                entry_error = true;
707                break;
708            };
709
710            let Ok(gen_str) = std::str::from_utf8(&data[pos + 11..pos + 16]) else {
711                entry_error = true;
712                break;
713            };
714            // Parse as u32 first, then clamp to u16 — some PDFs have
715            // generation 65536 which overflows u16 but is otherwise valid.
716            let Ok(gen_val) = gen_str.trim().parse::<u32>() else {
717                entry_error = true;
718                break;
719            };
720            let generation = gen_val.min(65535) as u16;
721
722            let type_byte = data[pos + 17];
723            let obj_num = first_obj as u32 + i;
724
725            let entry = match type_byte {
726                b'n' => XrefEntry::InFile {
727                    offset: off,
728                    generation,
729                },
730                b'f' => XrefEntry::Free,
731                _ => XrefEntry::Free,
732            };
733            entries.push((obj_num, entry));
734
735            // Advance past entry: skip to next line
736            pos += 18;
737            while pos < data.len()
738                && (data[pos] == b' ' || data[pos] == b'\r' || data[pos] == b'\n')
739            {
740                pos += 1;
741            }
742        }
743
744        // If an entry was malformed, stop parsing this xref section
745        if entry_error {
746            break;
747        }
748
749        // Skip any remaining whitespace
750        while pos < data.len() && is_whitespace(data[pos]) {
751            pos += 1;
752        }
753    }
754
755    // Parse trailer dict (or return empty if the trailer is missing —
756    // some broken incremental updates omit it entirely).
757    let trailer = if has_trailer {
758        let mut lexer = Lexer::at(data, pos);
759        lexer.set_pos(pos);
760        let tok = lexer.next_token()?;
761        match tok {
762            Token::DictBegin => parse_dict_body(&mut lexer)?,
763            _ => return Err(PdfError::MalformedTrailer),
764        }
765    } else if entries.is_empty() {
766        return Err(PdfError::MalformedXref(offset));
767    } else {
768        PdfDict::new()
769    };
770
771    Ok((entries, trailer))
772}
773
774/// Parse an xref stream at the given offset.
775fn parse_xref_stream(
776    data: &[u8],
777    offset: usize,
778) -> Result<(Vec<(u32, XrefEntry)>, PdfDict), PdfError> {
779    // Parse the indirect object header: N G obj
780    let mut lexer = Lexer::at(data, offset);
781
782    // Object number
783    let _obj_num = match lexer.next_token()? {
784        Token::Int(n) => n,
785        t => {
786            return Err(PdfError::UnexpectedToken {
787                expected: "object number".into(),
788                got: format!("{t:?}"),
789            });
790        }
791    };
792
793    // Generation number
794    match lexer.next_token()? {
795        Token::Int(_) => {}
796        t => {
797            return Err(PdfError::UnexpectedToken {
798                expected: "generation number".into(),
799                got: format!("{t:?}"),
800            });
801        }
802    }
803
804    // "obj" keyword
805    match lexer.next_token()? {
806        Token::Keyword(ref kw) if kw == b"obj" => {}
807        t => {
808            return Err(PdfError::UnexpectedToken {
809                expected: "obj".into(),
810                got: format!("{t:?}"),
811            });
812        }
813    }
814
815    // Parse the stream dict
816    match lexer.next_token()? {
817        Token::DictBegin => {}
818        t => {
819            return Err(PdfError::UnexpectedToken {
820                expected: "<<".into(),
821                got: format!("{t:?}"),
822            });
823        }
824    }
825    let dict = parse_dict_body(&mut lexer)?;
826
827    // Find stream data
828    let tok = lexer.next_token()?;
829    if !matches!(tok, Token::Keyword(ref kw) if kw == b"stream") {
830        return Err(PdfError::UnexpectedToken {
831            expected: "stream".into(),
832            got: format!("{tok:?}"),
833        });
834    }
835
836    // Stream data starts after "stream" + EOL
837    let mut data_start = lexer.pos();
838    if data_start < data.len() && data[data_start] == b'\r' {
839        data_start += 1;
840    }
841    if data_start < data.len() && data[data_start] == b'\n' {
842        data_start += 1;
843    }
844
845    // /Length may be a direct integer or — in malformed PDFs — an indirect
846    // reference that we cannot resolve at this stage (the resolver isn't
847    // built yet). When the direct integer isn't available, recover by
848    // scanning forward for the `endstream` keyword.
849    let length = match dict.get_int(b"Length") {
850        Some(n) => n as usize,
851        None => {
852            let needle = b"endstream";
853            let search_end = data.len().saturating_sub(needle.len());
854            let mut pos = data_start;
855            let mut found = None;
856            while pos <= search_end {
857                if &data[pos..pos + needle.len()] == needle {
858                    let mut end = pos;
859                    while end > data_start && matches!(data[end - 1], b' ' | b'\r' | b'\n') {
860                        end -= 1;
861                    }
862                    found = Some(end - data_start);
863                    break;
864                }
865                pos += 1;
866            }
867            found.ok_or(PdfError::StreamMissingLength)?
868        }
869    };
870    let raw_data = &data[data_start..std::cmp::min(data_start + length, data.len())];
871
872    // Decompress the stream
873    let (filter_list, parms) = filters::parse_filters(&dict, None)?;
874    let stream_data = if filter_list.is_empty() {
875        raw_data.to_vec()
876    } else {
877        filters::decode_stream(raw_data, &filter_list, &parms, None)?
878    };
879
880    // Parse xref stream entries
881    let w = dict.get_array(b"W").ok_or(PdfError::MissingKey("W"))?;
882    if w.len() != 3 {
883        return Err(PdfError::Other(
884            "xref stream /W must have 3 elements".into(),
885        ));
886    }
887    let w1 = w[0].as_int().unwrap_or(0) as usize;
888    let w2 = w[1].as_int().unwrap_or(0) as usize;
889    let w3 = w[2].as_int().unwrap_or(0) as usize;
890    let entry_size = w1 + w2 + w3;
891
892    if entry_size == 0 {
893        return Err(PdfError::Other("xref stream entry size is 0".into()));
894    }
895
896    // Parse /Index array (defaults to [0 Size])
897    let size = dict.get_int(b"Size").ok_or(PdfError::MissingKey("Size"))? as u32;
898
899    let index_pairs: Vec<(u32, u32)> = if let Some(index_arr) = dict.get_array(b"Index") {
900        index_arr
901            .chunks(2)
902            .filter_map(|pair| {
903                if pair.len() == 2 {
904                    Some((pair[0].as_int()? as u32, pair[1].as_int()? as u32))
905                } else {
906                    None
907                }
908            })
909            .collect()
910    } else {
911        vec![(0, size)]
912    };
913
914    let mut entries = Vec::new();
915    let mut stream_pos = 0;
916
917    for (first_obj, count) in &index_pairs {
918        for i in 0..*count {
919            if stream_pos + entry_size > stream_data.len() {
920                break;
921            }
922
923            let field1 = read_field(&stream_data[stream_pos..], w1);
924            let field2 = read_field(&stream_data[stream_pos + w1..], w2);
925            let field3 = read_field(&stream_data[stream_pos + w1 + w2..], w3);
926            stream_pos += entry_size;
927
928            // Default type is 1 when w1 == 0
929            let entry_type = if w1 == 0 { 1 } else { field1 };
930            let obj_num = first_obj + i;
931
932            let entry = match entry_type {
933                0 => XrefEntry::Free,
934                1 => XrefEntry::InFile {
935                    offset: field2 as usize,
936                    generation: field3 as u16,
937                },
938                2 => XrefEntry::InStream {
939                    stream_obj_num: field2 as u32,
940                    index_within: field3 as u16,
941                },
942                _ => XrefEntry::Free, // Unknown type, treat as free
943            };
944
945            entries.push((obj_num, entry));
946        }
947    }
948
949    // The xref stream dict IS the trailer
950    Ok((entries, dict))
951}
952
953/// Read a big-endian unsigned integer field of `width` bytes.
954fn read_field(data: &[u8], width: usize) -> u64 {
955    let mut val: u64 = 0;
956    for i in 0..width {
957        if i < data.len() {
958            val = (val << 8) | data[i] as u64;
959        }
960    }
961    val
962}
963
964/// Find all `%PDF-` header positions in the file.
965///
966/// Normal PDFs have one header at offset 0 (or after a BOM). Concatenated PDFs
967/// (e.g., an encrypted revision appended to a plaintext revision) have multiple
968/// headers. Offsets in later sections are relative to their own header.
969fn find_all_pdf_headers(data: &[u8]) -> Vec<usize> {
970    let mut headers = Vec::new();
971    let mut pos = 0;
972    while pos + 5 <= data.len() {
973        if &data[pos..pos + 5] == b"%PDF-" {
974            headers.push(pos);
975            pos += 5;
976        } else {
977            pos += 1;
978        }
979    }
980    headers
981}
982
983/// Determine which `%PDF-` header "owns" a given file position.
984///
985/// Returns the position of the last `%PDF-` header that appears before `pos`.
986/// For positions under the first header (or before any header), returns 0.
987fn owning_pdf_header(pos: usize, pdf_headers: &[usize]) -> usize {
988    // Find the last header that is <= pos
989    match pdf_headers.iter().rposition(|&h| h <= pos) {
990        Some(idx) if idx > 0 => pdf_headers[idx],
991        _ => 0,
992    }
993}
994
995/// Shift all `InFile` xref entries by a base offset.
996///
997/// Used when a xref section's entry offsets are relative to a secondary
998/// `%PDF-` header instead of the file start.
999fn shift_xref_entries(entries: &mut [(u32, XrefEntry)], base: usize) {
1000    for (_, entry) in entries.iter_mut() {
1001        if let XrefEntry::InFile { offset, .. } = entry {
1002            *offset += base;
1003        }
1004    }
1005}
1006
1007/// Find the `startxref` offset near the end of the file.
1008///
1009/// Some PDFs have trailing garbage after `%%EOF` (e.g. embedded attachments or
1010/// corrupted downloads), so we first locate the last `%%EOF` and search backwards
1011/// from there. Falls back to searching the last 1024 bytes if no `%%EOF` is found.
1012fn find_startxref(data: &[u8]) -> Result<usize, PdfError> {
1013    let needle = b"startxref";
1014
1015    // Strategy 1: Find the last %%EOF, then search backwards from it for startxref.
1016    // This handles PDFs with trailing garbage after the final %%EOF.
1017    let eof_marker = b"%%EOF";
1018    let mut eof_pos = None;
1019    for i in (0..data.len().saturating_sub(eof_marker.len())).rev() {
1020        if &data[i..i + eof_marker.len()] == eof_marker {
1021            eof_pos = Some(i);
1022            break;
1023        }
1024    }
1025
1026    if let Some(eof) = eof_pos {
1027        // Search the 1024 bytes before %%EOF for the last "startxref"
1028        let search_start = eof.saturating_sub(1024);
1029        let region = &data[search_start..eof];
1030        let mut found = None;
1031        for i in 0..region.len().saturating_sub(needle.len()) {
1032            if &region[i..i + needle.len()] == needle {
1033                found = Some(search_start + i);
1034            }
1035        }
1036        if let Some(pos) = found {
1037            let mut p = pos + needle.len();
1038            while p < data.len() && is_whitespace(data[p]) {
1039                p += 1;
1040            }
1041            let (offset, _) = parse_int_at(data, p)?;
1042            return Ok(offset as usize);
1043        }
1044    }
1045
1046    // Strategy 2: Fall back to searching the last 1024 bytes (no %%EOF found,
1047    // or startxref wasn't near the %%EOF).
1048    let search_start = data.len().saturating_sub(1024);
1049    let tail = &data[search_start..];
1050    let mut found = None;
1051    for i in 0..tail.len().saturating_sub(needle.len()) {
1052        if &tail[i..i + needle.len()] == needle {
1053            found = Some(search_start + i);
1054        }
1055    }
1056
1057    if let Some(pos) = found {
1058        let mut p = pos + needle.len();
1059        while p < data.len() && is_whitespace(data[p]) {
1060            p += 1;
1061        }
1062        let (offset, _) = parse_int_at(data, p)?;
1063        return Ok(offset as usize);
1064    }
1065
1066    // Strategy 3: No startxref at all (truncated file). Scan for the last
1067    // "xref" keyword and return its offset directly.
1068    let xref_kw = b"xref";
1069    let mut last_xref = None;
1070    for i in (0..data.len().saturating_sub(xref_kw.len())).rev() {
1071        if &data[i..i + xref_kw.len()] == xref_kw
1072            && (i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'\n')
1073        {
1074            last_xref = Some(i);
1075            break;
1076        }
1077    }
1078    last_xref.ok_or(PdfError::NoStartXref)
1079}
1080
1081/// Parse an integer starting at `pos`, return (value, new_pos).
1082fn parse_int_at(data: &[u8], pos: usize) -> Result<(i64, usize), PdfError> {
1083    let mut p = pos;
1084    // Skip leading whitespace
1085    while p < data.len() && is_whitespace(data[p]) {
1086        p += 1;
1087    }
1088    let start = p;
1089    if p < data.len() && (data[p] == b'+' || data[p] == b'-') {
1090        p += 1;
1091    }
1092    while p < data.len() && data[p].is_ascii_digit() {
1093        p += 1;
1094    }
1095    if p == start {
1096        return Err(PdfError::Other(format!("expected integer at offset {pos}")));
1097    }
1098    let s = std::str::from_utf8(&data[start..p])
1099        .map_err(|_| PdfError::Other(format!("invalid integer at offset {pos}")))?;
1100    let n: i64 = s
1101        .parse()
1102        .map_err(|_| PdfError::Other(format!("invalid integer '{s}' at offset {pos}")))?;
1103    Ok((n, p))
1104}
1105
1106fn is_whitespace(b: u8) -> bool {
1107    matches!(b, b' ' | b'\t' | b'\r' | b'\n' | 0x0C | 0x00)
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    #[test]
1115    fn find_startxref_simple() {
1116        let data = b"%PDF-1.4\nstartxref\n1234\n%%EOF\n";
1117        let offset = find_startxref(data).unwrap();
1118        assert_eq!(offset, 1234);
1119    }
1120
1121    #[test]
1122    fn find_startxref_trailing_garbage() {
1123        // Simulate a PDF with garbage data appended after %%EOF
1124        let mut data = b"%PDF-1.4\nstartxref\n5678\n%%EOF\n".to_vec();
1125        // Append 2000 bytes of garbage (more than the 1024-byte tail search)
1126        data.extend_from_slice(&[0xFFu8; 2000]);
1127        let offset = find_startxref(&data).unwrap();
1128        assert_eq!(offset, 5678);
1129    }
1130
1131    #[test]
1132    fn parse_int_at_basic() {
1133        let (val, pos) = parse_int_at(b"  42 rest", 0).unwrap();
1134        assert_eq!(val, 42);
1135        assert_eq!(pos, 4);
1136    }
1137
1138    #[test]
1139    fn read_field_sizes() {
1140        assert_eq!(read_field(&[0x01], 1), 1);
1141        assert_eq!(read_field(&[0x01, 0x00], 2), 256);
1142        assert_eq!(read_field(&[0x00, 0x01, 0x00], 3), 256);
1143    }
1144}