Skip to main content

oxidize_pdf/parser/
xref.rs

1//! PDF Cross-Reference Table Parser
2//!
3//! Parses xref tables according to ISO 32000-1 Section 7.5.4
4
5use super::xref_stream;
6use super::xref_types::{XRefEntryInfo, XRefEntryType};
7use super::{ParseError, ParseOptions, ParseResult};
8use crate::parser::reader::PDFLines;
9use std::collections::HashMap;
10use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
11
12// ============================================================================
13// Helper functions for byte-based pattern matching
14// (Issue #93: Avoid UTF-8 char boundary panics in XRef recovery)
15// ============================================================================
16
17/// Find a byte pattern in a buffer (replaces `str::find` for binary-safe searching).
18///
19/// Operates on raw bytes, so unlike `str::find` it never panics on UTF-8
20/// boundaries — PDFs are binary and may contain arbitrary byte sequences.
21///
22/// `pub(crate)` so sibling parser modules (e.g. `reader.rs`) reuse this single
23/// implementation instead of duplicating it (Issue #352).
24pub(crate) fn find_byte_pattern(buffer: &[u8], pattern: &[u8]) -> Option<usize> {
25    buffer
26        .windows(pattern.len())
27        .position(|window| window == pattern)
28}
29
30/// Find last occurrence of byte pattern (replaces String::rfind)
31fn rfind_byte_pattern(buffer: &[u8], pattern: &[u8]) -> Option<usize> {
32    buffer
33        .windows(pattern.len())
34        .rposition(|window| window == pattern)
35}
36
37/// Parse "N G obj" header from bytes
38///
39/// Converts only the small line to String for number parsing,
40/// avoiding UTF-8 issues with large buffer slicing.
41fn parse_obj_header_bytes(line_bytes: &[u8]) -> Option<(u32, u16)> {
42    // Convert only this small line to String (safe)
43    let line = String::from_utf8_lossy(line_bytes);
44    let parts: Vec<&str> = line.trim().split_whitespace().collect();
45
46    if parts.len() >= 3 && parts[2] == "obj" {
47        let obj_num = parts[0].parse::<u32>().ok()?;
48        let gen_num = parts[1].parse::<u16>().ok()?;
49        return Some((obj_num, gen_num));
50    }
51    None
52}
53
54/// A PDF object header (`N G obj`) located by a raw byte scan.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56struct ObjHeader {
57    obj_num: u32,
58    generation: u16,
59    /// Absolute byte offset of the start of the header line.
60    offset: u64,
61}
62
63/// Scan one in-memory window for `N G obj` headers, appending de-duplicated
64/// results (keyed by absolute line-start offset) to `out`.
65///
66/// `window_base` is the absolute file offset of `window[0]`.
67fn scan_window_for_headers(
68    window: &[u8],
69    window_base: u64,
70    out: &mut Vec<ObjHeader>,
71    seen: &mut std::collections::BTreeSet<u64>,
72) {
73    let mut pos = 0;
74    while pos < window.len() {
75        let Some(obj_rel) = find_byte_pattern(&window[pos..], b"obj") else {
76            break;
77        };
78        let abs = pos + obj_rel; // window-local index of 'o' in "obj"
79
80        // A header needs at least "N G " (4 bytes) before "obj".
81        if abs < 4 {
82            pos = abs + 3;
83            continue;
84        }
85
86        let line_start = window[..abs]
87            .iter()
88            .rposition(|&b| b == b'\n' || b == b'\r')
89            .map(|p| p + 1)
90            .unwrap_or(0);
91        let line_bytes = &window[line_start..abs + 3];
92
93        if let Some((obj_num, generation)) = parse_obj_header_bytes(line_bytes) {
94            let offset = window_base + line_start as u64;
95            if seen.insert(offset) {
96                out.push(ObjHeader {
97                    obj_num,
98                    generation,
99                    offset,
100                });
101            }
102        }
103
104        pos = abs + 3;
105    }
106}
107
108/// Scan a reader for `N G obj` headers using bounded memory (Issue #339).
109///
110/// Behaviourally equivalent to a full-buffer scan — find every `obj` keyword,
111/// walk back to the start of its line, parse `N G obj` — but reads the file in
112/// fixed-size chunks with a small line carry-over, so peak memory is O(CHUNK)
113/// regardless of file size instead of O(file). Results are returned in
114/// ascending offset order, de-duplicated by line-start offset.
115fn scan_object_headers<R: Read + Seek>(reader: &mut R) -> ParseResult<Vec<ObjHeader>> {
116    scan_object_headers_chunked(reader, 64 * 1024)
117}
118
119/// Chunked implementation of [`scan_object_headers`] with an explicit chunk size
120/// (the public entry point fixes it at 64 KiB; tests vary it to exercise chunk
121/// boundaries cheaply).
122fn scan_object_headers_chunked<R: Read + Seek>(
123    reader: &mut R,
124    chunk_size: usize,
125) -> ParseResult<Vec<ObjHeader>> {
126    let chunk_size = chunk_size.max(1);
127    // Generously larger than the longest possible "N G obj" line so any header
128    // straddling a chunk boundary is preserved without unbounded carry growth.
129    const CARRY_CAP: usize = 1024;
130
131    reader.seek(SeekFrom::Start(0))?;
132
133    let mut headers: Vec<ObjHeader> = Vec::new();
134    let mut seen: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
135    let mut carry: Vec<u8> = Vec::new();
136    let mut window_base: u64 = 0; // absolute offset of carry[0]
137    let mut chunk = vec![0u8; chunk_size];
138
139    loop {
140        // Read may return short; fill up to chunk_size bytes.
141        let mut filled = 0;
142        while filled < chunk_size {
143            let n = reader.read(&mut chunk[filled..])?;
144            if n == 0 {
145                break;
146            }
147            filled += n;
148        }
149        let eof = filled == 0;
150        if eof && carry.is_empty() {
151            break;
152        }
153
154        // window = carry ++ freshly read bytes
155        let mut window = std::mem::take(&mut carry);
156        window.extend_from_slice(&chunk[..filled]);
157
158        scan_window_for_headers(&window, window_base, &mut headers, &mut seen);
159
160        if eof {
161            break;
162        }
163
164        // Carry from the last line boundary, bounded to CARRY_CAP so the next
165        // window can see a header that straddles this chunk boundary.
166        let last_nl = window.iter().rposition(|&b| b == b'\n' || b == b'\r');
167        let mut start = last_nl.map(|p| p + 1).unwrap_or(0);
168        if window.len() - start > CARRY_CAP {
169            start = window.len() - CARRY_CAP;
170        }
171        window_base += start as u64;
172        carry = window[start..].to_vec();
173    }
174
175    headers.sort_by_key(|h| h.offset);
176    Ok(headers)
177}
178
179/// Read up to `max` bytes starting at absolute `offset`. Bounded memory: the
180/// returned buffer is at most `max` bytes regardless of file size.
181pub(crate) fn read_window_at<R: Read + Seek>(
182    reader: &mut R,
183    offset: u64,
184    max: usize,
185) -> ParseResult<Vec<u8>> {
186    reader.seek(SeekFrom::Start(offset))?;
187    let mut buf = vec![0u8; max];
188    let mut filled = 0;
189    while filled < max {
190        let n = reader.read(&mut buf[filled..])?;
191        if n == 0 {
192            break;
193        }
194        filled += n;
195    }
196    buf.truncate(filled);
197    Ok(buf)
198}
199
200/// Read the last `max` bytes of the file (or the whole file if shorter),
201/// returning `(start_offset, bytes)`. Bounded to `max` bytes.
202fn read_tail<R: Read + Seek>(reader: &mut R, max: usize) -> ParseResult<(u64, Vec<u8>)> {
203    let len = reader.seek(SeekFrom::End(0))?;
204    let start = len.saturating_sub(max as u64);
205    let bytes = read_window_at(reader, start, (len - start) as usize)?;
206    Ok((start, bytes))
207}
208
209/// Read object `obj_num`'s content window starting at its xref `offset`,
210/// trimmed at the first `endobj`, as a (lossy) string. Returns `None` if the
211/// `N 0 obj` header is not present within the bounded window.
212fn read_object_content<R: Read + Seek>(
213    reader: &mut R,
214    obj_num: u32,
215    offset: u64,
216) -> ParseResult<Option<String>> {
217    const OBJ_WINDOW: usize = 64 * 1024;
218    let window = read_window_at(reader, offset, OBJ_WINDOW)?;
219    let obj_pattern = format!("{obj_num} 0 obj");
220    let Some(obj_start) = find_byte_pattern(&window, obj_pattern.as_bytes()) else {
221        return Ok(None);
222    };
223    let Some(endobj_rel) = find_byte_pattern(&window[obj_start..], b"endobj") else {
224        return Ok(None);
225    };
226    let content_bytes = &window[obj_start..obj_start + endobj_rel];
227    Ok(Some(String::from_utf8_lossy(content_bytes).into_owned()))
228}
229
230/// Locate the first (lowest-offset) `obj_num G obj` header via the bounded
231/// chunked scan, stopping as soon as it is found — so locating an object near
232/// the front of a large file does not read the whole tail. Returns its absolute
233/// line-start offset, or `None` if no such header exists. Peak memory is O(chunk).
234///
235/// First-occurrence semantics match the prior whole-file `find("N 0 obj")` used
236/// by the manual-extraction fallbacks (Issue #339). This is the resolver for
237/// simple manual lookups (e.g. an indirect `/Length` integer); the recovery-path
238/// *reconstruction* that must honour incremental-update last-write-wins
239/// (Issue #426) is handled separately in `merge_object_headers` /
240/// `parse_with_recovery_options`, which keep the latest header per object.
241fn find_object_offset<R: Read + Seek>(reader: &mut R, obj_num: u32) -> ParseResult<Option<u64>> {
242    const CHUNK_SIZE: usize = 64 * 1024;
243    const CARRY_CAP: usize = 1024;
244
245    reader.seek(SeekFrom::Start(0))?;
246
247    let mut carry: Vec<u8> = Vec::new();
248    let mut window_base: u64 = 0; // absolute offset of carry[0]
249    let mut chunk = vec![0u8; CHUNK_SIZE];
250
251    loop {
252        let mut filled = 0;
253        while filled < CHUNK_SIZE {
254            let n = reader.read(&mut chunk[filled..])?;
255            if n == 0 {
256                break;
257            }
258            filled += n;
259        }
260        let eof = filled == 0;
261        if eof && carry.is_empty() {
262            break;
263        }
264
265        let mut window = std::mem::take(&mut carry);
266        window.extend_from_slice(&chunk[..filled]);
267
268        // Scan this window; return the first match in ascending offset order.
269        let mut headers = Vec::new();
270        let mut seen = std::collections::BTreeSet::new();
271        scan_window_for_headers(&window, window_base, &mut headers, &mut seen);
272        if let Some(header) = headers.iter().find(|h| h.obj_num == obj_num) {
273            return Ok(Some(header.offset));
274        }
275
276        if eof {
277            break;
278        }
279
280        // Carry the last partial line so a header straddling the chunk boundary
281        // is seen in full by the next window (bounded to CARRY_CAP).
282        let last_nl = window.iter().rposition(|&b| b == b'\n' || b == b'\r');
283        let mut start = last_nl.map(|p| p + 1).unwrap_or(0);
284        if window.len() - start > CARRY_CAP {
285            start = window.len() - CARRY_CAP;
286        }
287        window_base += start as u64;
288        carry = window[start..].to_vec();
289    }
290
291    Ok(None)
292}
293
294/// Locate object `obj_num` via the bounded early-stopping scan and return its
295/// byte offset plus a bounded window of `max` bytes starting at that offset.
296/// Returns `None` if no `N G obj` header for `obj_num` exists.
297///
298/// Peak memory is O(scan chunk + `max`) regardless of file size — this is the
299/// bounded replacement for the whole-file `read_to_end` manual-extraction
300/// fallbacks in `reader.rs` (Issue #339).
301pub(crate) fn read_object_window<R: Read + Seek>(
302    reader: &mut R,
303    obj_num: u32,
304    max: usize,
305) -> ParseResult<Option<(u64, Vec<u8>)>> {
306    let Some(offset) = find_object_offset(reader, obj_num)? else {
307        return Ok(None);
308    };
309    let window = read_window_at(reader, offset, max)?;
310    Ok(Some((offset, window)))
311}
312
313/// Scan the whole file in bounded chunks for objects whose dictionary declares
314/// `/Type /Page` (excluding the page-tree node `/Type /Pages`), returning
315/// `(obj_num, 0)` for each in ascending, de-duplicated order.
316///
317/// Peak memory is O(scan chunk + probe window) regardless of file size — the
318/// bounded replacement for the whole-file `read_to_end` page scan in `reader.rs`
319/// (Issue #339). Each header is probed with a small bounded window at its offset,
320/// and the match is confined to the object's own body (up to `endobj`).
321pub(crate) fn scan_page_object_refs<R: Read + Seek>(
322    reader: &mut R,
323) -> ParseResult<Vec<(u32, u16)>> {
324    const PROBE: usize = 4 * 1024;
325
326    let headers = scan_object_headers(reader)?;
327    let mut pages = Vec::new();
328    for header in &headers {
329        let window = read_window_at(reader, header.offset, PROBE)?;
330        // Confine the match to this object's own body so a later object's /Type
331        // cannot leak into this one.
332        let region = match find_byte_pattern(&window, b"endobj") {
333            Some(end) => &window[..end],
334            None => &window[..],
335        };
336        let text = String::from_utf8_lossy(region);
337        // PDF names need no whitespace before the value: both "/Type /Page" and the
338        // compact "/Type/Page" are valid (ISO 32000-1 §7.3.5). Match both, and
339        // exclude the page-tree node /Type /Pages in either spelling.
340        let is_page = text.contains("/Type /Page") || text.contains("/Type/Page");
341        let is_pages = text.contains("/Type /Pages") || text.contains("/Type/Pages");
342        if is_page && !is_pages {
343            pages.push((header.obj_num, 0));
344        }
345    }
346    pages.sort_unstable();
347    pages.dedup();
348    Ok(pages)
349}
350
351/// Read a line handling both CR (\r) and LF (\n) as line terminators.
352///
353/// PDF files can use CR, LF, or CRLF as line endings (ISO 32000-1 Section 7.2.3).
354/// Standard `BufRead::read_line()` only handles LF, causing issues with CR-only PDFs.
355///
356/// Returns the number of bytes read (including line terminator).
357fn read_pdf_line<R: BufRead>(reader: &mut R, buf: &mut String) -> std::io::Result<usize> {
358    buf.clear();
359    let mut total_bytes = 0;
360
361    loop {
362        let available = reader.fill_buf()?;
363        if available.is_empty() {
364            // EOF reached
365            break;
366        }
367
368        // Find the first CR or LF
369        let mut found_terminator = false;
370        let mut consume_len = 0;
371
372        for (i, &byte) in available.iter().enumerate() {
373            if byte == b'\r' || byte == b'\n' {
374                // Found a line terminator
375                // Include content up to (not including) the terminator
376                let content = &available[..i];
377                buf.push_str(&String::from_utf8_lossy(content));
378                consume_len = i + 1; // Consume content + terminator
379
380                // Check for CRLF sequence
381                if byte == b'\r' && i + 1 < available.len() && available[i + 1] == b'\n' {
382                    consume_len += 1; // Also consume the LF
383                }
384
385                found_terminator = true;
386                break;
387            }
388        }
389
390        if found_terminator {
391            reader.consume(consume_len);
392            total_bytes += consume_len;
393            break;
394        } else {
395            // No terminator found in buffer, consume all and continue
396            let len = available.len();
397            buf.push_str(&String::from_utf8_lossy(available));
398            reader.consume(len);
399            total_bytes += len;
400        }
401    }
402
403    Ok(total_bytes)
404}
405
406// ============================================================================
407
408/// Cross-reference entry (traditional format)
409#[derive(Debug, Clone, Copy, PartialEq)]
410pub struct XRefEntry {
411    /// Byte offset in the file
412    pub offset: u64,
413    /// Generation number
414    pub generation: u16,
415    /// Whether the object is in use
416    pub in_use: bool,
417}
418
419/// Extended XRef entry information for compressed objects
420#[derive(Debug, Clone, PartialEq)]
421pub struct XRefEntryExt {
422    /// Basic entry information
423    pub basic: XRefEntry,
424    /// Additional info for compressed objects
425    pub compressed_info: Option<(u32, u32)>, // (stream_obj_num, index_in_stream)
426}
427
428/// Cross-reference table
429#[derive(Debug, Clone)]
430pub struct XRefTable {
431    /// Map of object number to xref entry
432    entries: HashMap<u32, XRefEntry>,
433    /// Extended entries for compressed objects
434    extended_entries: HashMap<u32, XRefEntryExt>,
435    /// Trailer dictionary
436    trailer: Option<super::objects::PdfDictionary>,
437    /// Offset of the xref table in the file
438    xref_offset: u64,
439}
440
441impl Default for XRefTable {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447impl XRefTable {
448    /// Create a new empty xref table
449    pub fn new() -> Self {
450        Self {
451            entries: HashMap::new(),
452            extended_entries: HashMap::new(),
453            trailer: None,
454            xref_offset: 0,
455        }
456    }
457
458    /// Get all entries in the xref table
459    pub fn entries(&self) -> &HashMap<u32, XRefEntry> {
460        &self.entries
461    }
462
463    /// Parse xref table from a reader with fallback recovery
464    pub fn parse<R: Read + Seek>(reader: &mut BufReader<R>) -> ParseResult<Self> {
465        Self::parse_with_options(reader, &super::ParseOptions::default())
466    }
467
468    /// Parse xref table from a reader with custom options
469    pub fn parse_with_options<R: Read + Seek>(
470        reader: &mut BufReader<R>,
471        options: &super::ParseOptions,
472    ) -> ParseResult<Self> {
473        // Try normal parsing first
474        match Self::parse_with_incremental_updates_options(reader, options) {
475            Ok(table) => Ok(table),
476            Err(e) => {
477                // Reconstructing a missing/corrupt cross-reference table by
478                // scanning object headers is standard robustness behaviour for
479                // a PDF reader (poppler/pdf.js/qpdf all do it), NOT a syntax
480                // tolerance. Issue #374: files without a `startxref`/`xref`
481                // (e.g. poppler fuzzing fixtures) must still be recoverable via
482                // `PdfReader::new`. Gate recovery on `max_recovery_attempts > 0`
483                // — the semantic "recovery allowed" knob — so `default()`
484                // (attempts = 3) and `tolerant()` (attempts = 5) reconstruct,
485                // while `strict()` (attempts = 0) keeps failing loudly.
486                if options.max_recovery_attempts > 0 {
487                    tracing::warn!("Primary XRef parsing failed: {e:?}, attempting recovery");
488
489                    // Reset reader position and try recovery
490                    reader.seek(SeekFrom::Start(0))?;
491                    Self::parse_with_recovery_options(reader, options)
492                } else {
493                    Err(e)
494                }
495            }
496        }
497    }
498
499    /// Parse xref table with support for incremental updates
500    #[allow(dead_code)]
501    fn parse_with_incremental_updates<R: Read + Seek>(
502        reader: &mut BufReader<R>,
503    ) -> ParseResult<Self> {
504        Self::parse_with_incremental_updates_options(reader, &super::ParseOptions::default())
505    }
506
507    /// Parse xref table with support for incremental updates and options
508    fn parse_with_incremental_updates_options<R: Read + Seek>(
509        reader: &mut BufReader<R>,
510        options: &super::ParseOptions,
511    ) -> ParseResult<Self> {
512        // Find the most recent xref offset
513        let xref_offset = Self::find_xref_offset(reader)?;
514
515        // Parse all xref tables in the chain
516        let mut merged_table = Self::new();
517        let mut current_offset = Some(xref_offset);
518        let mut visited_offsets = std::collections::HashSet::new();
519
520        while let Some(offset) = current_offset {
521            // Prevent infinite loops
522            if visited_offsets.contains(&offset) {
523                tracing::debug!(
524                    "Circular reference in XRef chain at offset {} (already visited)",
525                    offset
526                );
527                break;
528            }
529            visited_offsets.insert(offset);
530
531            // Parse the xref table at this offset
532            reader.seek(SeekFrom::Start(offset))?;
533            let table = Self::parse_primary_with_options(reader, options)?;
534
535            // Get the previous offset from trailer
536            let prev_offset = table
537                .trailer
538                .as_ref()
539                .and_then(|t| t.get("Prev"))
540                .and_then(|obj| obj.as_integer())
541                .map(|i| i as u64);
542
543            if let Some(_prev) = prev_offset {
544            } else {
545            }
546
547            // Merge entries (newer entries override older ones)
548            let _regular_count = table.entries.len();
549            let _extended_count = table.extended_entries.len();
550
551            for (obj_num, entry) in table.entries {
552                merged_table.entries.entry(obj_num).or_insert(entry);
553            }
554            for (obj_num, ext_entry) in table.extended_entries {
555                merged_table
556                    .extended_entries
557                    .entry(obj_num)
558                    .or_insert(ext_entry);
559            }
560
561            // Use the most recent trailer
562            if merged_table.trailer.is_none() {
563                merged_table.trailer = table.trailer;
564                merged_table.xref_offset = table.xref_offset;
565            }
566
567            current_offset = prev_offset;
568        }
569
570        // Check if we have a hybrid-reference file (XRef stream with missing objects)
571        // This happens when the PDF has direct objects (1-N) that aren't listed in XRef streams
572        // Typical for Skia/PDF and other optimized generators
573        if options.lenient_syntax || options.collect_warnings {
574            // Scan for objects that exist in the PDF but aren't in the XRef.
575            // This is necessary for hybrid files where the XRef stream only lists
576            // some objects. scan_object_headers seeks to the start itself, so no
577            // explicit rewind is needed here.
578            //
579            // Best-effort: the hybrid scan is supplementary, so a failure here must
580            // not abort parsing — but it must not be swallowed silently either.
581            if let Err(e) = Self::scan_and_fill_missing_objects(reader, &mut merged_table) {
582                tracing::debug!("scan_and_fill_missing_objects failed (non-fatal): {e}");
583            }
584        }
585
586        Ok(merged_table)
587    }
588
589    /// Parse xref table from a reader (handles both traditional and stream xrefs)
590    #[allow(dead_code)]
591    fn parse_primary<R: Read + Seek>(reader: &mut BufReader<R>) -> ParseResult<Self> {
592        Self::parse_primary_with_options(reader, &super::ParseOptions::default())
593    }
594
595    /// Parse xref table from a reader with options
596    ///
597    /// Note: This expects the reader to already be positioned at the xref offset.
598    /// For the primary xref (from startxref), the caller should position the reader.
599    /// For /Prev chain xrefs, the reader is already positioned at the correct offset.
600    fn parse_primary_with_options<R: Read + Seek>(
601        reader: &mut BufReader<R>,
602        options: &super::ParseOptions,
603    ) -> ParseResult<Self> {
604        let mut table = Self::new();
605
606        // The reader should already be positioned at the correct xref offset
607        // (either from startxref for primary, or from /Prev for chain entries)
608        // We record the current position as our xref offset
609        let xref_offset = reader.stream_position()?;
610        table.xref_offset = xref_offset;
611
612        // Check if this is a traditional xref table or xref stream
613        // Use read_pdf_line to handle CR-only line endings (e.g., HP Scan PDFs)
614        let mut line = String::new();
615        let pos = reader.stream_position()?;
616        read_pdf_line(reader, &mut line)?;
617
618        if line.trim() == "xref" {
619            // Traditional xref table
620            Self::parse_traditional_xref_with_options(reader, &mut table, options)?;
621        } else {
622            tracing::debug!(
623                "Not a traditional xref, checking for xref stream. Line: {:?}",
624                line.trim()
625            );
626
627            // Might be an xref stream, seek back
628            reader.seek(SeekFrom::Start(pos))?;
629
630            // Try to parse as an object
631            let mut lexer = super::lexer::Lexer::new_with_options(&mut *reader, options.clone());
632
633            // Read object header
634            let obj_num = match lexer.next_token()? {
635                super::lexer::Token::Integer(n) => n as u32,
636                _ => return Err(ParseError::InvalidXRef),
637            };
638
639            tracing::debug!("Found object {obj_num} at xref position");
640
641            let _gen_num = match lexer.next_token()? {
642                super::lexer::Token::Integer(n) => n as u16,
643                _ => return Err(ParseError::InvalidXRef),
644            };
645
646            match lexer.next_token()? {
647                super::lexer::Token::Obj => {}
648                _ => return Err(ParseError::InvalidXRef),
649            };
650
651            // Parse the object (should be a stream)
652            let obj = super::objects::PdfObject::parse_with_options(&mut lexer, options)?;
653
654            if let Some(stream) = obj.as_stream() {
655                // Check if it's an xref stream
656                if stream
657                    .dict
658                    .get("Type")
659                    .and_then(|o| o.as_name())
660                    .map(|n| n.as_str())
661                    == Some("XRef")
662                {
663                    tracing::debug!("Parsing XRef stream");
664
665                    // Decode the stream exactly once. `XRefStream::parse` treats
666                    // its input as already decoded (issue #341), so on decode
667                    // failure we must NOT hand it the raw compressed bytes — they
668                    // are not valid xref entries and only ever "worked" because
669                    // `parse` used to re-decode them. Propagate the error so the
670                    // caller falls back to recovery mode instead.
671                    let decoded_data = match stream.decode(options) {
672                        Ok(data) => data,
673                        Err(e) => {
674                            tracing::warn!(
675                                "XRef stream decode failed: {e:?}, triggering recovery mode"
676                            );
677                            return Err(e);
678                        }
679                    };
680
681                    // Use the new xref_stream module
682                    let xref_stream_parser = xref_stream::XRefStream::parse(
683                        &mut *reader,
684                        stream.dict.clone(),
685                        decoded_data,
686                        options,
687                    )?;
688
689                    // Convert entries to our format
690                    let entries = xref_stream_parser.to_xref_entries()?;
691                    tracing::debug!("XRef stream parsed, found {} entries", entries.len());
692
693                    // Copy entries from xref stream
694                    for (obj_num, entry) in entries {
695                        match entry {
696                            xref_stream::XRefEntry::Free {
697                                next_free_object,
698                                generation,
699                            } => {
700                                table.entries.insert(
701                                    obj_num,
702                                    XRefEntry {
703                                        offset: next_free_object as u64,
704                                        generation,
705                                        in_use: false,
706                                    },
707                                );
708                            }
709                            xref_stream::XRefEntry::InUse { offset, generation } => {
710                                table.entries.insert(
711                                    obj_num,
712                                    XRefEntry {
713                                        offset,
714                                        generation,
715                                        in_use: true,
716                                    },
717                                );
718                            }
719                            xref_stream::XRefEntry::Compressed {
720                                stream_object_number,
721                                index_within_stream,
722                            } => {
723                                // Create extended entry for compressed object
724                                let ext_entry = XRefEntryExt {
725                                    basic: XRefEntry {
726                                        offset: 0,
727                                        generation: 0,
728                                        in_use: true,
729                                    },
730                                    compressed_info: Some((
731                                        stream_object_number,
732                                        index_within_stream,
733                                    )),
734                                };
735                                table.extended_entries.insert(obj_num, ext_entry);
736                                table.entries.insert(
737                                    obj_num,
738                                    XRefEntry {
739                                        offset: 0,
740                                        generation: 0,
741                                        in_use: true,
742                                    },
743                                );
744                            }
745                        }
746                    }
747
748                    // Set trailer from xref stream
749                    table.trailer = Some(xref_stream_parser.trailer_dict().clone());
750                } else {
751                    return Err(ParseError::InvalidXRef);
752                }
753            } else {
754                return Err(ParseError::InvalidXRef);
755            }
756        }
757
758        Ok(table)
759    }
760
761    /// Parse traditional xref table
762    #[allow(dead_code)]
763    fn parse_traditional_xref<R: Read + Seek>(
764        reader: &mut BufReader<R>,
765        table: &mut XRefTable,
766    ) -> ParseResult<()> {
767        Self::parse_traditional_xref_with_options(reader, table, &super::ParseOptions::default())
768    }
769
770    /// Parse traditional xref table with options
771    fn parse_traditional_xref_with_options<R: Read + Seek>(
772        reader: &mut BufReader<R>,
773        table: &mut XRefTable,
774        options: &super::ParseOptions,
775    ) -> ParseResult<()> {
776        let mut line = String::new();
777        let mut trailer_dict_offset: Option<u64> = None;
778
779        // Parse subsections
780        // Use read_pdf_line to handle CR-only line endings (e.g., HP Scan PDFs)
781        loop {
782            line.clear();
783            let line_start_pos = reader.stream_position()?;
784            read_pdf_line(reader, &mut line)?;
785            let trimmed_line = line.trim();
786
787            // Skip empty lines and comments
788            if trimmed_line.is_empty() || trimmed_line.starts_with('%') {
789                continue;
790            }
791
792            // Check if we've reached the trailer
793            // Note: Some PDFs use \r instead of \n as line separator, so "trailer\r<<..."
794            // may appear as a single line. Use starts_with() instead of exact match.
795            if trimmed_line == "trailer" {
796                // Normal case: trailer keyword on its own line
797                break;
798            }
799            if let Some(dict_pos) = trimmed_line.find("<<") {
800                if trimmed_line.starts_with("trailer") {
801                    // Trailer keyword followed by dict on same line (e.g., "trailer\r<<...>>")
802                    // Calculate the offset to the << in the original file
803                    let trailer_keyword_start =
804                        trimmed_line.as_ptr() as usize - line.as_ptr() as usize;
805                    trailer_dict_offset =
806                        Some(line_start_pos + (trailer_keyword_start + dict_pos) as u64);
807                    break;
808                }
809            }
810
811            // Also check if the line looks like a trailer (might have been reached prematurely)
812            if trimmed_line.starts_with("<<") {
813                tracing::warn!(" Found trailer dictionary without 'trailer' keyword");
814                // Seek back to the start of this line so lexer can parse it
815                trailer_dict_offset = Some(line_start_pos);
816                break;
817            }
818
819            // Parse subsection header (first_obj_num count)
820            let parts: Vec<&str> = trimmed_line.split_whitespace().collect();
821            if parts.len() != 2 {
822                // Invalid subsection header
823                return Err(ParseError::InvalidXRef);
824            }
825
826            let first_obj_num = parts[0]
827                .parse::<u32>()
828                .map_err(|_| ParseError::InvalidXRef)?;
829            let count = parts[1]
830                .parse::<u32>()
831                .map_err(|_| ParseError::InvalidXRef)?;
832
833            // Parse entries
834            // Parse xref entries
835            let mut entries_parsed = 0;
836            let mut i = 0;
837            while i < count {
838                line.clear();
839                let bytes_read = read_pdf_line(reader, &mut line)?;
840                let trimmed = line.trim();
841
842                // Skip comments
843                if trimmed.starts_with('%') {
844                    continue;
845                }
846
847                // Check if we've hit EOF or trailer prematurely
848                if bytes_read == 0 || trimmed == "trailer" {
849                    tracing::debug!(
850                        "Warning: XRef subsection incomplete - expected {count} entries but found only {entries_parsed}"
851                    );
852                    // Put the "trailer" line back for the next phase
853                    if line.trim() == "trailer" {
854                        // Can't put it back easily, so we'll handle this case later
855                        break;
856                    }
857                    break;
858                }
859
860                match Self::parse_xref_entry(&line) {
861                    Ok(entry) => {
862                        table.entries.insert(first_obj_num + i, entry);
863                        entries_parsed += 1;
864                    }
865                    Err(_) => {
866                        tracing::debug!(
867                            "Warning: Invalid XRef entry at position {}: {:?}",
868                            i,
869                            line.trim()
870                        );
871                        // Continue parsing to get as much as possible
872                    }
873                }
874                i += 1;
875            }
876            // Finished parsing xref entries
877        }
878
879        // Parse trailer dictionary
880        // If we found the trailer dict embedded in the same line (e.g., "trailer\r<<...>>"),
881        // seek to that position first
882        if let Some(offset) = trailer_dict_offset {
883            reader.seek(SeekFrom::Start(offset))?;
884        }
885        let mut lexer = super::lexer::Lexer::new_with_options(reader, options.clone());
886        let trailer_obj = super::objects::PdfObject::parse_with_options(&mut lexer, options)?;
887        // Trailer object parsed successfully
888
889        table.trailer = trailer_obj.as_dict().cloned();
890
891        // Validate xref table against trailer Size
892        if let Some(trailer) = &table.trailer {
893            if let Some(size_obj) = trailer.get("Size") {
894                if let Some(expected_size) = size_obj.as_integer() {
895                    // Check if the highest object number + 1 matches the Size
896                    // Note: PDFs can have gaps in object numbers, so we check the max, not the count
897                    if let Some(max_obj_num) = table.entries.keys().max() {
898                        let max_expected = (*max_obj_num + 1) as i64;
899                        if max_expected > expected_size {
900                            tracing::debug!(
901                                "Warning: XRef table has object {} but trailer Size is only {}",
902                                max_obj_num,
903                                expected_size
904                            );
905                            // Don't fail here, let the recovery mode handle it
906                            return Err(ParseError::InvalidXRef);
907                        }
908                    }
909                }
910            }
911        }
912
913        // After parsing the trailer, the reader is positioned after the dictionary
914        // We don't need to parse anything else - startxref/offset/%%EOF are handled elsewhere
915
916        Ok(())
917    }
918
919    /// Find linearized XRef by checking if there's an XRef stream near the beginning.
920    ///
921    /// NOTE: This function was previously used incorrectly in `parse_primary_with_options`
922    /// which caused Issue #98 (linearized PDFs failing to find Pages object).
923    /// The function is preserved for potential future use in detecting linearized PDFs,
924    /// but should NOT be used to override the XRef offset from startxref.
925    #[allow(dead_code)]
926    fn find_linearized_xref<R: Read + Seek>(reader: &mut BufReader<R>) -> ParseResult<u64> {
927        // Skip PDF header
928        reader.seek(SeekFrom::Start(0))?;
929        let mut header = String::new();
930        reader.read_line(&mut header)?;
931
932        if !header.starts_with("%PDF-") {
933            return Err(ParseError::InvalidHeader);
934        }
935
936        // Skip any binary marker line
937        let mut line = String::new();
938        reader.read_line(&mut line)?;
939
940        // Now we should be at the first object if this is linearized
941        // Read a bit more to check
942        let pos = reader.stream_position()?;
943        let mut buffer = vec![0u8; 1024];
944        let bytes_read = reader.read(&mut buffer)?;
945        buffer.truncate(bytes_read);
946
947        // FIX for Issue #93: Use byte-based operations to avoid UTF-8 boundary panics
948        // Look for patterns that indicate a linearized PDF
949        // Linearized PDFs typically have a linearization dictionary as the first object
950        tracing::debug!(
951            "Checking for linearized PDF, first 100 bytes: {:?}",
952            String::from_utf8_lossy(&buffer[..buffer.len().min(100)])
953        );
954
955        // Check for /Linearized pattern
956        if find_byte_pattern(&buffer, b"/Linearized").is_some() {
957            // This is likely a linearized PDF
958            // The XRef is usually right after the linearization dictionary
959            // Look for either "xref" or an XRef stream object
960
961            // First, try to find "xref" keyword
962            if let Some(xref_pos) = find_byte_pattern(&buffer, b"xref") {
963                return Ok(pos + xref_pos as u64);
964            }
965
966            // Otherwise, look for an XRef stream (object with /Type /XRef)
967            if find_byte_pattern(&buffer, b"/Type/XRef").is_some()
968                || find_byte_pattern(&buffer, b"/Type /XRef").is_some()
969            {
970                // Need to parse to find the exact position
971                // For now, we'll use a heuristic
972                if let Some(obj_pos) = find_byte_pattern(&buffer, b" obj") {
973                    // Look for the next object after linearization dict
974                    let search_from = obj_pos + 4;
975                    if search_from < buffer.len() {
976                        let after_first_obj = &buffer[search_from..];
977                        if let Some(next_obj) = find_byte_pattern(after_first_obj, b" obj") {
978                            // Position of second object
979                            let second_obj_start =
980                                pos + (search_from + next_obj).saturating_sub(10) as u64;
981                            return Ok(second_obj_start);
982                        }
983                    }
984                }
985            }
986        }
987
988        Err(ParseError::InvalidXRef)
989    }
990
991    /// Find the xref offset by looking for startxref at the end of the file
992    fn find_xref_offset<R: Read + Seek>(reader: &mut BufReader<R>) -> ParseResult<u64> {
993        // Go to end of file
994        reader.seek(SeekFrom::End(0))?;
995        let file_size = reader.stream_position()?;
996
997        // Read last 1024 bytes (should be enough for EOL + startxref + offset + %%EOF)
998        let read_size = std::cmp::min(1024, file_size);
999        reader.seek(SeekFrom::End(-(read_size as i64)))?;
1000
1001        let mut buffer = vec![0u8; read_size as usize];
1002        reader.read_exact(&mut buffer)?;
1003
1004        // Convert to string and find startxref
1005        let content = String::from_utf8_lossy(&buffer);
1006
1007        // Debug: print last part of file
1008        let debug_content = content.chars().take(200).collect::<String>();
1009        tracing::debug!("XRef search in last {read_size} bytes: {debug_content:?}");
1010
1011        let mut lines = content.pdf_lines();
1012
1013        // Find the LAST startxref in the tail window. An incremental update
1014        // (ISO 32000-1 §7.5.6) appends a new `startxref`/`%%EOF` after the
1015        // original; the most recent one points at the newest cross-reference
1016        // section and MUST take precedence (earlier ones are reached via the
1017        // `/Prev` chain). Returning the first occurrence would read the base
1018        // PDF and silently ignore every appended update.
1019        let mut last_offset = None;
1020        while let Some(line) = lines.next() {
1021            if line.trim() == "startxref" {
1022                // The offset should be on the next line
1023                if let Some(offset_line) = lines.next() {
1024                    if let Ok(offset) = offset_line.trim().parse::<u64>() {
1025                        last_offset = Some(offset);
1026                    }
1027                }
1028            }
1029        }
1030
1031        last_offset.ok_or(ParseError::InvalidXRef)
1032    }
1033
1034    /// Scan PDF for objects not present in XRef and add them (for hybrid files)
1035    fn scan_and_fill_missing_objects<R: Read + Seek>(
1036        reader: &mut BufReader<R>,
1037        table: &mut Self,
1038    ) -> ParseResult<()> {
1039        // Bounded-memory scan (Issue #339): locate object headers in fixed-size
1040        // chunks instead of reading the entire file into a Vec, then add them
1041        // with incremental-update last-write-wins semantics (Issue #426).
1042        let scanned = scan_object_headers(reader)?;
1043        table.add_headers_latest_wins(&scanned, true);
1044
1045        Ok(())
1046    }
1047
1048    /// Parse XRef table using recovery mode (scan for objects)
1049    #[allow(dead_code)]
1050    fn parse_with_recovery<R: Read + Seek>(reader: &mut BufReader<R>) -> ParseResult<Self> {
1051        Self::parse_with_recovery_options(reader, &super::ParseOptions::default())
1052    }
1053
1054    /// Parse XRef table using recovery mode with options
1055    fn parse_with_recovery_options<R: Read + Seek>(
1056        reader: &mut BufReader<R>,
1057        options: &super::ParseOptions,
1058    ) -> ParseResult<Self> {
1059        // Bounded-memory recovery (Issue #339): scan object headers in fixed-size
1060        // chunks and resolve the catalog through per-object / file-tail windows,
1061        // instead of reading the whole file into a Vec.
1062        const ROOT_TAIL: usize = 256 * 1024;
1063        const CATALOG_TAIL: usize = 100 * 1024;
1064
1065        let mut table = Self::new();
1066
1067        // 1) Locate object headers (bounded scan) and add them with
1068        //    incremental-update last-write-wins semantics (ISO 32000-1 §7.5.6,
1069        //    Issue #426): a redefined object resolves to its most recent revision.
1070        let headers = scan_object_headers(reader)?;
1071        table.add_headers_latest_wins(&headers, false);
1072
1073        if table.entries.is_empty() {
1074            return Err(ParseError::InvalidXRef);
1075        }
1076        tracing::debug!("XRef recovery: found {} objects", table.len());
1077
1078        // 2) Prefer /Root declared in an XRef stream. The XRef stream / trailer
1079        //    conventionally lives at the end of the file, so scan a bounded tail.
1080        let (_, root_tail) = read_tail(reader, ROOT_TAIL)?;
1081        let root_tail_str = String::from_utf8_lossy(&root_tail);
1082        let xref_root_candidate = extract_root_from_xref_stream(&root_tail_str);
1083
1084        // 3) Build a minimal trailer.
1085        let mut trailer = super::objects::PdfDictionary::new();
1086        trailer.insert(
1087            "Size".to_string(),
1088            super::objects::PdfObject::Integer(table.len() as i64),
1089        );
1090
1091        // 3b) Preserve `/Encrypt` and `/ID` from the original classic trailer so
1092        //     a reconstructed ENCRYPTED document is not silently opened as
1093        //     plaintext (Issue #374 fail-safe). Dropping `/Encrypt` here would
1094        //     make `EncryptionHandler::detect_encryption` return false and the
1095        //     reader would treat ciphertext as cleartext.
1096        let (encrypt, id) = extract_encrypt_and_id_from_trailer(&root_tail, options);
1097        if let Some(encrypt) = encrypt {
1098            trailer.insert("Encrypt".to_string(), encrypt);
1099        }
1100        if let Some(id) = id {
1101            trailer.insert("ID".to_string(), id);
1102        }
1103
1104        // 4) Resolve the catalog object.
1105        let mut catalog_candidate = None;
1106
1107        // 4a) Root from the XRef stream, if it points at a known object.
1108        if let Some(xref_root) = xref_root_candidate {
1109            if table.entries.contains_key(&xref_root) {
1110                catalog_candidate = Some(xref_root);
1111                tracing::debug!("Using Root {} from XRef stream as catalog", xref_root);
1112            } else {
1113                tracing::debug!(
1114                    "Warning: XRef Root {} not found in object table, searching manually",
1115                    xref_root
1116                );
1117            }
1118        }
1119
1120        // 4b) Validate object structure by content.
1121        if catalog_candidate.is_none() {
1122            catalog_candidate = find_catalog_by_content(reader, &table)?;
1123        }
1124
1125        // 4c) Fallback to common object numbers (Issue #83: validate type).
1126        if catalog_candidate.is_none() {
1127            for obj_num in [1, 2, 3, 4, 5] {
1128                let offset = match table.entries.get(&obj_num) {
1129                    Some(entry) if entry.in_use => entry.offset,
1130                    _ => continue,
1131                };
1132                if let Some(content) = read_object_content(reader, obj_num, offset)? {
1133                    // Skip /Type/Sig objects (digital signatures).
1134                    if content.contains("/Type/Sig") || content.contains("/Type /Sig") {
1135                        tracing::debug!("Skipping object {} (Type: Sig)", obj_num);
1136                        continue;
1137                    }
1138                    if content.contains("/Type/Catalog")
1139                        || content.contains("/Type /Catalog")
1140                        || content.contains("/Pages")
1141                    {
1142                        catalog_candidate = Some(obj_num);
1143                        tracing::debug!(
1144                            "Using fallback catalog candidate: object {} (validated)",
1145                            obj_num
1146                        );
1147                        break;
1148                    }
1149                }
1150            }
1151        }
1152
1153        // 4d) Last resort: scan ALL objects (sorted) for /Type/Catalog or /Pages.
1154        if catalog_candidate.is_none() && !table.entries.is_empty() {
1155            tracing::debug!(
1156                "Last resort: Scanning all {} objects for any with /Pages or /Catalog",
1157                table.entries.len()
1158            );
1159
1160            let mut obj_numbers: Vec<u32> = table.entries.keys().copied().collect();
1161            obj_numbers.sort_unstable();
1162
1163            for obj_num in obj_numbers {
1164                let offset = match table.entries.get(&obj_num) {
1165                    Some(entry) if entry.in_use => entry.offset,
1166                    _ => continue,
1167                };
1168                if let Some(content) = read_object_content(reader, obj_num, offset)? {
1169                    if content.contains("/Type/Sig") || content.contains("/Type /Sig") {
1170                        continue;
1171                    }
1172                    if content.contains("/Type/Catalog") || content.contains("/Type /Catalog") {
1173                        catalog_candidate = Some(obj_num);
1174                        tracing::debug!(
1175                            "Last resort: Found catalog at object {} (/Type/Catalog)",
1176                            obj_num
1177                        );
1178                        break;
1179                    } else if content.contains("/Pages") {
1180                        catalog_candidate = Some(obj_num);
1181                        tracing::debug!(
1182                            "Last resort: Found catalog at object {} (has /Pages)",
1183                            obj_num
1184                        );
1185                        break;
1186                    }
1187                }
1188            }
1189
1190            // 4e) Extreme last resort: scan the last 100KB for /Type/Catalog and
1191            //     walk back to its "N 0 obj" header (Issue #83/#93).
1192            if catalog_candidate.is_none() {
1193                tracing::debug!("Extreme last resort: Scanning last 100KB for /Type/Catalog");
1194
1195                let (_, search_buffer) = read_tail(reader, CATALOG_TAIL)?;
1196                if let Some(catalog_pos) = rfind_byte_pattern(&search_buffer, b"/Type/Catalog") {
1197                    let local_search_start = catalog_pos.saturating_sub(200);
1198                    let search_area = &search_buffer[local_search_start..catalog_pos];
1199
1200                    if let Some(obj_pattern_pos) = rfind_byte_pattern(search_area, b" 0 obj") {
1201                        let before_obj = &search_area[..obj_pattern_pos];
1202                        let before_obj_str = String::from_utf8_lossy(before_obj);
1203                        let trimmed = before_obj_str.trim_end();
1204
1205                        if let Some((digit_start, ch)) = trimmed
1206                            .char_indices()
1207                            .rev()
1208                            .find(|(_, c)| !c.is_ascii_digit())
1209                        {
1210                            let num_str = trimmed[digit_start + ch.len_utf8()..].trim();
1211                            if !num_str.is_empty() {
1212                                if let Ok(obj_num) = num_str.parse::<u32>() {
1213                                    tracing::debug!(
1214                                        "Extreme last resort: Found /Type/Catalog at object {}",
1215                                        obj_num
1216                                    );
1217                                    catalog_candidate = Some(obj_num);
1218                                }
1219                            }
1220                        } else if let Ok(obj_num) = trimmed.trim().parse::<u32>() {
1221                            tracing::debug!(
1222                                "Extreme last resort: Found /Type/Catalog at object {}",
1223                                obj_num
1224                            );
1225                            catalog_candidate = Some(obj_num);
1226                        }
1227                    }
1228                } else {
1229                    tracing::debug!("Extreme last resort: No /Type/Catalog found in last 100KB");
1230                }
1231            }
1232
1233            // 4f) Absolute last resort: first non-signature object in the table.
1234            if catalog_candidate.is_none() {
1235                tracing::warn!(" Could not find any catalog object, using first non-signature object as absolute last resort");
1236                let mut obj_numbers: Vec<u32> = table.entries.keys().copied().collect();
1237                obj_numbers.sort_unstable();
1238                for obj_num in obj_numbers {
1239                    let offset = match table.entries.get(&obj_num) {
1240                        Some(entry) => entry.offset,
1241                        None => continue,
1242                    };
1243                    if let Some(content) = read_object_content(reader, obj_num, offset)? {
1244                        if !content.contains("/Type/Sig") && !content.contains("/Type /Sig") {
1245                            catalog_candidate = Some(obj_num);
1246                            tracing::debug!("Using object {} as absolute last resort", obj_num);
1247                            break;
1248                        }
1249                    }
1250                }
1251            }
1252        }
1253
1254        if let Some(root_obj) = catalog_candidate {
1255            trailer.insert(
1256                "Root".to_string(),
1257                super::objects::PdfObject::Reference(root_obj, 0),
1258            );
1259        }
1260
1261        table.set_trailer(trailer);
1262
1263        Ok(table)
1264    }
1265
1266    /// Parse object header from line
1267    /// Validate XRef offset before using it.
1268    ///
1269    /// NOTE: This function was previously used in the buggy linearized XRef handling
1270    /// that caused Issue #98. Currently unused but preserved for potential future use.
1271    #[allow(dead_code)]
1272    fn validate_offset<R: Read + Seek>(reader: &mut BufReader<R>, offset: u64) -> ParseResult<()> {
1273        // Get file size
1274        let file_size = reader.seek(SeekFrom::End(0))?;
1275
1276        if offset >= file_size {
1277            #[cfg(debug_assertions)]
1278            tracing::warn!(" XRef offset {offset} exceeds file size {file_size}");
1279            return Err(ParseError::InvalidXRef);
1280        }
1281
1282        // Check if offset points to valid content
1283        reader.seek(SeekFrom::Start(offset))?;
1284        let mut peek = [0u8; 20];
1285        let read_bytes = reader.read(&mut peek)?;
1286
1287        if read_bytes == 0 {
1288            #[cfg(debug_assertions)]
1289            tracing::warn!(" XRef offset {offset} points to EOF");
1290            return Err(ParseError::InvalidXRef);
1291        }
1292
1293        // FIX for Issue #93: Use byte-based operations to avoid UTF-8 boundary panics
1294        // Look for expected XRef markers
1295        let peek_slice = &peek[..read_bytes];
1296        let starts_with_xref = peek_slice.len() >= 4 && &peek_slice[..4] == b"xref";
1297        let starts_with_digit = peek_slice.first().map_or(false, |&b| b.is_ascii_digit());
1298
1299        if !starts_with_xref && !starts_with_digit {
1300            #[cfg(debug_assertions)]
1301            {
1302                let debug_len = std::cmp::min(10, read_bytes);
1303                let debug_content = String::from_utf8_lossy(&peek[..debug_len]);
1304                tracing::debug!(
1305                    "Warning: XRef offset {} does not point to valid XRef content: {:?}",
1306                    offset,
1307                    debug_content
1308                );
1309            }
1310            // Don't fail here, as some PDFs might have variations
1311        }
1312
1313        Ok(())
1314    }
1315
1316    /// Parse a single xref entry line (enhanced with flexible parsing)
1317    fn parse_xref_entry(line: &str) -> ParseResult<XRefEntry> {
1318        let line = line.trim();
1319
1320        // First try standard format: nnnnnnnnnn ggggg n/f
1321        if line.len() >= 18 {
1322            if let Ok(entry) = Self::parse_xref_entry_standard(line) {
1323                return Ok(entry);
1324            }
1325        }
1326
1327        // If standard parsing fails, try flexible parsing
1328        Self::parse_xref_entry_flexible(line)
1329    }
1330
1331    /// Parse XRef entry using standard fixed-width format
1332    fn parse_xref_entry_standard(line: &str) -> ParseResult<XRefEntry> {
1333        // Entry format: nnnnnnnnnn ggggg n/f
1334        // Where n = offset (10 digits), g = generation (5 digits), n/f = in use flag
1335        if line.len() < 18 {
1336            return Err(ParseError::InvalidXRef);
1337        }
1338
1339        let offset_str = &line[0..10];
1340        let gen_str = &line[11..16];
1341        let flag = line.chars().nth(17);
1342
1343        let offset = offset_str
1344            .trim()
1345            .parse::<u64>()
1346            .map_err(|_| ParseError::InvalidXRef)?;
1347        let generation = gen_str
1348            .trim()
1349            .parse::<u16>()
1350            .map_err(|_| ParseError::InvalidXRef)?;
1351
1352        let in_use = match flag {
1353            Some('n') => true,
1354            Some('f') => false,
1355            _ => return Err(ParseError::InvalidXRef),
1356        };
1357
1358        Ok(XRefEntry {
1359            offset,
1360            generation,
1361            in_use,
1362        })
1363    }
1364
1365    /// Parse XRef entry using flexible whitespace-based format
1366    fn parse_xref_entry_flexible(line: &str) -> ParseResult<XRefEntry> {
1367        // Handle variations like:
1368        // - Extra spaces: "0000000017  00000  n"
1369        // - Missing spaces: "0000000017 00000n"
1370        // - Different padding: "17 0 n"
1371        // - Tabs instead of spaces
1372
1373        // Split by any whitespace and filter empty parts
1374        let parts: Vec<&str> = line.split_whitespace().collect();
1375
1376        if parts.is_empty() {
1377            return Err(ParseError::InvalidXRef);
1378        }
1379
1380        // Extract offset
1381        let offset = parts[0]
1382            .parse::<u64>()
1383            .map_err(|_| ParseError::InvalidXRef)?;
1384
1385        // Extract generation (default to 0 if missing)
1386        let (generation, flag_from_gen) = if parts.len() >= 2 {
1387            let gen_part = parts[1];
1388            // Check if this is just a flag character (n or f)
1389            if gen_part == "n" || gen_part == "f" {
1390                // This is just the flag, generation defaults to 0
1391                (0, gen_part.chars().next())
1392            } else if gen_part.ends_with('n') || gen_part.ends_with('f') {
1393                // Flag is attached to generation (e.g., "0n", "1f")
1394                let flag_char = gen_part.chars().last().ok_or(ParseError::InvalidXRef)?;
1395                let gen_str = &gen_part[..gen_part.len() - 1];
1396                if gen_str.is_empty() {
1397                    // Just the flag, no generation number
1398                    (0, Some(flag_char))
1399                } else {
1400                    let gen = gen_str
1401                        .parse::<u16>()
1402                        .map_err(|_| ParseError::InvalidXRef)?;
1403                    (gen, Some(flag_char))
1404                }
1405            } else {
1406                // Try to parse as generation number
1407                let gen = gen_part
1408                    .parse::<u16>()
1409                    .map_err(|_| ParseError::InvalidXRef)?;
1410                (gen, None)
1411            }
1412        } else {
1413            (0, None)
1414        };
1415
1416        // Extract flag (default to 'n' if missing or invalid)
1417        let in_use = if let Some(flag_char) = flag_from_gen {
1418            // Flag was attached to generation
1419            match flag_char {
1420                'n' => true,
1421                'f' => false,
1422                _ => true, // Default to in-use
1423            }
1424        } else if parts.len() >= 3 {
1425            // Flag is separate
1426            match parts[2].chars().next() {
1427                Some('n') => true,
1428                Some('f') => false,
1429                _ => {
1430                    // Unknown flag, log warning in debug mode and assume in-use
1431                    #[cfg(debug_assertions)]
1432                    tracing::warn!(" Invalid xref flag '{}', assuming 'n'", parts[2]);
1433                    true
1434                }
1435            }
1436        } else {
1437            // Missing flag, assume in-use
1438            true
1439        };
1440
1441        Ok(XRefEntry {
1442            offset,
1443            generation,
1444            in_use,
1445        })
1446    }
1447
1448    /// Get an xref entry by object number
1449    pub fn get_entry(&self, obj_num: u32) -> Option<&XRefEntry> {
1450        self.entries.get(&obj_num)
1451    }
1452
1453    /// Get a mutable xref entry by object number
1454    pub fn get_entry_mut(&mut self, obj_num: u32) -> Option<&mut XRefEntry> {
1455        self.entries.get_mut(&obj_num)
1456    }
1457
1458    /// Get the trailer dictionary
1459    pub fn trailer(&self) -> Option<&super::objects::PdfDictionary> {
1460        self.trailer.as_ref()
1461    }
1462
1463    /// Get the xref offset
1464    pub fn xref_offset(&self) -> u64 {
1465        self.xref_offset
1466    }
1467
1468    /// Get the number of entries
1469    pub fn len(&self) -> usize {
1470        self.entries.len()
1471    }
1472
1473    /// Check if the table is empty
1474    pub fn is_empty(&self) -> bool {
1475        self.entries.is_empty()
1476    }
1477
1478    /// Iterate over all entries
1479    pub fn iter(&self) -> impl Iterator<Item = (&u32, &XRefEntry)> {
1480        self.entries.iter()
1481    }
1482
1483    /// Get extended entry information (for compressed objects)
1484    pub fn get_extended_entry(&self, obj_num: u32) -> Option<&XRefEntryExt> {
1485        self.extended_entries.get(&obj_num)
1486    }
1487
1488    /// Check if an object is compressed
1489    pub fn is_compressed(&self, obj_num: u32) -> bool {
1490        self.extended_entries
1491            .get(&obj_num)
1492            .map(|e| e.compressed_info.is_some())
1493            .unwrap_or(false)
1494    }
1495
1496    /// Add an entry to the xref table
1497    pub fn add_entry(&mut self, obj_num: u32, entry: XRefEntry) {
1498        self.entries.insert(obj_num, entry);
1499    }
1500
1501    /// Add scanned object `headers` with incremental-update semantics: the LAST
1502    /// (highest-offset) definition of each object number wins, so an object
1503    /// redefined by an appended update resolves to its most recent revision
1504    /// (ISO 32000-1 §7.5.6, Issue #426). An entry already resolved from a valid
1505    /// xref is never overridden; `check_extended` also protects compressed-object
1506    /// (`extended_entries`) resolutions. `headers` is assumed ascending by offset.
1507    fn add_headers_latest_wins(&mut self, headers: &[ObjHeader], check_extended: bool) {
1508        let mut latest: HashMap<u32, &ObjHeader> = HashMap::new();
1509        for h in headers {
1510            latest.insert(h.obj_num, h); // ascending ⇒ last wins
1511        }
1512        for (obj_num, h) in latest {
1513            let occupied = self.entries.contains_key(&obj_num)
1514                || (check_extended && self.extended_entries.contains_key(&obj_num));
1515            if !occupied {
1516                self.add_entry(
1517                    obj_num,
1518                    XRefEntry {
1519                        offset: h.offset,
1520                        generation: h.generation,
1521                        in_use: true,
1522                    },
1523                );
1524            }
1525        }
1526    }
1527
1528    /// Set the trailer dictionary
1529    pub fn set_trailer(&mut self, trailer: super::objects::PdfDictionary) {
1530        self.trailer = Some(trailer);
1531    }
1532
1533    /// Add an extended entry to the xref table
1534    pub fn add_extended_entry(&mut self, obj_num: u32, entry: XRefEntryExt) {
1535        self.extended_entries.insert(obj_num, entry);
1536    }
1537}
1538
1539/// Cross-reference stream (PDF 1.5+)
1540/// This is a more compact representation using streams
1541#[derive(Debug, Clone)]
1542pub struct XRefStream {
1543    /// The stream object containing xref data
1544    stream: super::objects::PdfStream,
1545    /// Decoded entries
1546    entries: HashMap<u32, XRefEntry>,
1547    /// Extended entries for compressed objects
1548    extended_entries: HashMap<u32, XRefEntryExt>,
1549}
1550
1551impl XRefStream {
1552    /// Parse an xref stream object
1553    pub fn parse(stream: super::objects::PdfStream) -> ParseResult<Self> {
1554        let mut xref_stream = Self {
1555            stream,
1556            entries: HashMap::new(),
1557            extended_entries: HashMap::new(),
1558        };
1559
1560        xref_stream.decode_entries()?;
1561        Ok(xref_stream)
1562    }
1563
1564    /// Decode the xref stream entries
1565    fn decode_entries(&mut self) -> ParseResult<()> {
1566        // Get stream dictionary values
1567        let dict = &self.stream.dict;
1568
1569        // Get the Size (number of entries)
1570        let size = dict
1571            .get("Size")
1572            .and_then(|obj| obj.as_integer())
1573            .ok_or_else(|| ParseError::MissingKey("Size".to_string()))?;
1574
1575        // Get the Index array [first_obj_num, count, ...]
1576        let index = match dict.get("Index") {
1577            Some(obj) => {
1578                let array = obj.as_array().ok_or_else(|| ParseError::SyntaxError {
1579                    position: 0,
1580                    message: "Index must be an array".to_string(),
1581                })?;
1582
1583                // Convert to pairs of (first_obj_num, count)
1584                let mut pairs = Vec::new();
1585                for chunk in array.0.chunks(2) {
1586                    if chunk.len() != 2 {
1587                        return Err(ParseError::SyntaxError {
1588                            position: 0,
1589                            message: "Index array must have even number of elements".to_string(),
1590                        });
1591                    }
1592                    let first = chunk[0]
1593                        .as_integer()
1594                        .ok_or_else(|| ParseError::SyntaxError {
1595                            position: 0,
1596                            message: "Index values must be integers".to_string(),
1597                        })? as u32;
1598                    let count = chunk[1]
1599                        .as_integer()
1600                        .ok_or_else(|| ParseError::SyntaxError {
1601                            position: 0,
1602                            message: "Index values must be integers".to_string(),
1603                        })? as u32;
1604                    pairs.push((first, count));
1605                }
1606                pairs
1607            }
1608            None => {
1609                // Default: single subsection starting at 0
1610                vec![(0, size as u32)]
1611            }
1612        };
1613
1614        // Get the W array (field widths)
1615        let w_array = dict
1616            .get("W")
1617            .and_then(|obj| obj.as_array())
1618            .ok_or_else(|| ParseError::MissingKey("W".to_string()))?;
1619
1620        if w_array.len() != 3 {
1621            return Err(ParseError::SyntaxError {
1622                position: 0,
1623                message: "W array must have exactly 3 elements".to_string(),
1624            });
1625        }
1626
1627        let w: Vec<usize> = w_array
1628            .0
1629            .iter()
1630            .map(|obj| {
1631                obj.as_integer()
1632                    .ok_or_else(|| ParseError::SyntaxError {
1633                        position: 0,
1634                        message: "W values must be integers".to_string(),
1635                    })
1636                    .map(|i| i as usize)
1637            })
1638            .collect::<ParseResult<Vec<_>>>()?;
1639
1640        // Decode the stream data
1641        let data = self.stream.decode(&ParseOptions::default())?;
1642        let mut offset = 0;
1643
1644        // Process each subsection
1645        for (first_obj_num, count) in index {
1646            for i in 0..count {
1647                if offset + w[0] + w[1] + w[2] > data.len() {
1648                    return Err(ParseError::SyntaxError {
1649                        position: 0,
1650                        message: "Xref stream data truncated".to_string(),
1651                    });
1652                }
1653
1654                // Read fields according to widths
1655                let field1 = Self::read_field(&data[offset..], w[0]);
1656                offset += w[0];
1657
1658                let field2 = Self::read_field(&data[offset..], w[1]);
1659                offset += w[1];
1660
1661                let field3 = Self::read_field(&data[offset..], w[2]);
1662                offset += w[2];
1663
1664                // Parse entry type and create entry info
1665                let entry_info =
1666                    XRefEntryInfo::new(XRefEntryType::from_value(field1), field2, field3);
1667
1668                // Create XRefEntry based on type
1669                let entry = match entry_info.entry_type {
1670                    XRefEntryType::Free => XRefEntry {
1671                        offset: entry_info.field2,
1672                        generation: entry_info.field3 as u16,
1673                        in_use: false,
1674                    },
1675                    XRefEntryType::Uncompressed => XRefEntry {
1676                        offset: entry_info.field2,
1677                        generation: entry_info.field3 as u16,
1678                        in_use: true,
1679                    },
1680                    XRefEntryType::Compressed => {
1681                        // Store extended info for compressed objects
1682                        let ext_entry = XRefEntryExt {
1683                            basic: XRefEntry {
1684                                offset: 0,
1685                                generation: 0,
1686                                in_use: true,
1687                            },
1688                            compressed_info: entry_info.get_compressed_info(),
1689                        };
1690                        self.extended_entries
1691                            .insert(first_obj_num + i, ext_entry.clone());
1692                        ext_entry.basic
1693                    }
1694                    XRefEntryType::Custom(_type_num) => {
1695                        // Custom types are treated as in-use objects
1696                        // Log only in debug mode to avoid spam
1697                        #[cfg(debug_assertions)]
1698                        tracing::debug!(
1699                            "Note: Custom xref entry type {} for object {} (treating as in-use)",
1700                            _type_num,
1701                            first_obj_num + i
1702                        );
1703
1704                        // Store as extended entry with custom type info
1705                        let ext_entry = XRefEntryExt {
1706                            basic: XRefEntry {
1707                                offset: entry_info.field2,
1708                                generation: entry_info.field3 as u16,
1709                                in_use: entry_info.entry_type.is_in_use(),
1710                            },
1711                            compressed_info: None,
1712                        };
1713                        self.extended_entries
1714                            .insert(first_obj_num + i, ext_entry.clone());
1715                        ext_entry.basic
1716                    }
1717                };
1718
1719                self.entries.insert(first_obj_num + i, entry);
1720            }
1721        }
1722
1723        Ok(())
1724    }
1725
1726    /// Read a field of given width from data
1727    fn read_field(data: &[u8], width: usize) -> u64 {
1728        let mut value = 0u64;
1729        for i in 0..width {
1730            if i < data.len() {
1731                value = (value << 8) | (data[i] as u64);
1732            }
1733        }
1734        value
1735    }
1736
1737    /// Get an entry by object number
1738    pub fn get_entry(&self, obj_num: u32) -> Option<&XRefEntry> {
1739        self.entries.get(&obj_num)
1740    }
1741
1742    /// Get the trailer dictionary from the stream
1743    pub fn trailer(&self) -> &super::objects::PdfDictionary {
1744        &self.stream.dict
1745    }
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750    use super::*;
1751
1752    use crate::parser::objects::{PdfDictionary, PdfObject};
1753    use std::io::Cursor;
1754
1755    // ---- Issue #339: bounded-memory object-header scanner ----
1756
1757    #[test]
1758    fn test_scan_object_headers_finds_simple_headers() {
1759        let mut buf = Vec::new();
1760        buf.extend_from_slice(b"%PDF-1.7\n");
1761        let off1 = buf.len() as u64;
1762        buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
1763        let off2 = buf.len() as u64;
1764        buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages >>\nendobj\n");
1765        let off10 = buf.len() as u64;
1766        buf.extend_from_slice(b"10 0 obj\n<< /Length 0 >>\nendobj\n");
1767
1768        let mut cursor = Cursor::new(buf);
1769        let headers = scan_object_headers(&mut cursor).unwrap();
1770
1771        assert_eq!(
1772            headers,
1773            vec![
1774                ObjHeader {
1775                    obj_num: 1,
1776                    generation: 0,
1777                    offset: off1
1778                },
1779                ObjHeader {
1780                    obj_num: 2,
1781                    generation: 0,
1782                    offset: off2
1783                },
1784                ObjHeader {
1785                    obj_num: 10,
1786                    generation: 0,
1787                    offset: off10
1788                },
1789            ]
1790        );
1791    }
1792
1793    #[test]
1794    fn test_scan_object_headers_chunk_invariant_across_boundaries() {
1795        // Headers placed at varied offsets so small chunk sizes force many to
1796        // straddle chunk boundaries. The result must not depend on chunk size.
1797        let mut buf = Vec::new();
1798        buf.extend_from_slice(b"%PDF-1.7\n");
1799        let mut expected: Vec<(u32, u64)> = Vec::new();
1800        for i in 1..=50u32 {
1801            for _ in 0..(i as usize % 7) {
1802                buf.push(b' ');
1803            }
1804            buf.push(b'\n');
1805            expected.push((i, buf.len() as u64));
1806            buf.extend_from_slice(format!("{i} 0 obj\n<< /N {i} >>\nendobj\n").as_bytes());
1807        }
1808
1809        // Single-window scan (chunk >= file length) is the reference.
1810        let reference =
1811            scan_object_headers_chunked(&mut Cursor::new(buf.clone()), buf.len().max(1)).unwrap();
1812
1813        let got: Vec<(u32, u64)> = reference.iter().map(|h| (h.obj_num, h.offset)).collect();
1814        assert_eq!(
1815            got, expected,
1816            "reference scan disagrees with hand-computed offsets"
1817        );
1818
1819        for cs in [1usize, 2, 3, 7, 13, 16, 64, 256] {
1820            let chunked = scan_object_headers_chunked(&mut Cursor::new(buf.clone()), cs).unwrap();
1821            assert_eq!(chunked, reference, "scan mismatch at chunk_size={cs}");
1822        }
1823    }
1824
1825    #[test]
1826    fn test_scan_object_headers_ignores_endobj_keyword() {
1827        // "endobj" contains "obj" but must never be parsed as a header.
1828        let mut buf = Vec::new();
1829        buf.extend_from_slice(b"%PDF\n");
1830        let off = buf.len() as u64;
1831        buf.extend_from_slice(b"7 0 obj\n<< >>\nendobj\nendobj\n");
1832
1833        let headers = scan_object_headers(&mut Cursor::new(buf)).unwrap();
1834        assert_eq!(
1835            headers,
1836            vec![ObjHeader {
1837                obj_num: 7,
1838                generation: 0,
1839                offset: off
1840            }]
1841        );
1842    }
1843
1844    #[test]
1845    fn test_scan_object_headers_carry_truncation_no_newline_run() {
1846        // Adversarial: a >CARRY_CAP (1024) run with NO newline, which forces the
1847        // carry-truncation branch. Verifies the chunked scan still equals the
1848        // single-window reference — i.e. truncation never emits a header at a
1849        // wrong offset (refutes the "incorrect carry offset" hypothesis).
1850        let mut buf = Vec::new();
1851        buf.extend_from_slice(b"%PDF-1.7\n");
1852
1853        // 2000 bytes of non-newline filler (includes spaces/digits to be nasty).
1854        let filler: Vec<u8> = (0..2000u32)
1855            .map(|i| if i % 5 == 0 { b' ' } else { b'7' })
1856            .collect();
1857
1858        // Case A: real header AFTER a newline that follows the long no-newline run.
1859        buf.extend_from_slice(&filler);
1860        buf.push(b'\n');
1861        let off_a = buf.len() as u64;
1862        buf.extend_from_slice(b"5 0 obj\n<< >>\nendobj\n");
1863
1864        // Case B: "7 0 obj" GLUED to a long no-newline run (not at a line start);
1865        // must be treated identically by both scans.
1866        buf.extend_from_slice(&filler);
1867        buf.extend_from_slice(b"7 0 obj\n<< >>\nendobj\n");
1868
1869        let reference =
1870            scan_object_headers_chunked(&mut Cursor::new(buf.clone()), buf.len().max(1)).unwrap();
1871
1872        // Chunk sizes far below CARRY_CAP force the truncation path repeatedly.
1873        for cs in [16usize, 64, 256] {
1874            let chunked = scan_object_headers_chunked(&mut Cursor::new(buf.clone()), cs).unwrap();
1875            assert_eq!(
1876                chunked, reference,
1877                "carry-truncation mismatch at chunk_size={cs}"
1878            );
1879        }
1880
1881        // The header preceded by a newline is found at its true offset; the glued
1882        // one (no line start) is not a valid header in either scan.
1883        assert!(reference
1884            .iter()
1885            .any(|h| h.obj_num == 5 && h.offset == off_a));
1886        assert!(!reference.iter().any(|h| h.obj_num == 7));
1887    }
1888
1889    #[test]
1890    fn test_scan_object_headers_empty_input() {
1891        let headers = scan_object_headers(&mut Cursor::new(Vec::new())).unwrap();
1892        assert!(headers.is_empty());
1893    }
1894
1895    #[test]
1896    fn test_scan_object_headers_reads_in_bounded_chunks() {
1897        // Instrumented reader recording the largest single `read` request.
1898        struct MaxReadReader<R> {
1899            inner: R,
1900            max_read: usize,
1901        }
1902        impl<R: Read> Read for MaxReadReader<R> {
1903            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1904                self.max_read = self.max_read.max(buf.len());
1905                self.inner.read(buf)
1906            }
1907        }
1908        impl<R: Seek> Seek for MaxReadReader<R> {
1909            fn seek(&mut self, p: SeekFrom) -> std::io::Result<u64> {
1910                self.inner.seek(p)
1911            }
1912        }
1913
1914        let mut buf = Vec::new();
1915        buf.extend_from_slice(b"%PDF\n");
1916        for i in 1..=2000u32 {
1917            buf.extend_from_slice(format!("{i} 0 obj\n<< >>\nendobj\n").as_bytes());
1918        }
1919        let total = buf.len();
1920        assert!(
1921            total > 8192,
1922            "fixture must exceed the chunk size to be meaningful"
1923        );
1924
1925        let mut r = MaxReadReader {
1926            inner: Cursor::new(buf),
1927            max_read: 0,
1928        };
1929        let headers = scan_object_headers_chunked(&mut r, 4096).unwrap();
1930
1931        assert_eq!(headers.len(), 2000);
1932        assert_eq!(headers[0].obj_num, 1);
1933        assert_eq!(headers[1999].obj_num, 2000);
1934        // A read_to_end-based scan would request the whole remaining file in one
1935        // growing buffer; a chunked scan never asks for more than one chunk.
1936        assert!(
1937            r.max_read <= 4096,
1938            "scanner requested {} bytes in a single read (chunk=4096, file={total}); not bounded",
1939            r.max_read
1940        );
1941    }
1942
1943    #[test]
1944    fn test_read_object_window_bounded_and_correct() {
1945        // Issue #339 follow-up: reader.rs manual-extraction fallbacks must locate
1946        // an object via the bounded chunked scanner + a bounded window read,
1947        // never `read_to_end` over the whole file.
1948        //
1949        // Instrumented reader recording the largest single `read` request: a
1950        // `read_to_end`-based locate grows one buffer to ~file size; a bounded
1951        // locate never requests more than one scan chunk (64 KiB) or one window.
1952        struct MaxReadReader<R> {
1953            inner: R,
1954            max_read: usize,
1955        }
1956        impl<R: Read> Read for MaxReadReader<R> {
1957            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1958                self.max_read = self.max_read.max(buf.len());
1959                self.inner.read(buf)
1960            }
1961        }
1962        impl<R: Seek> Seek for MaxReadReader<R> {
1963            fn seek(&mut self, p: SeekFrom) -> std::io::Result<u64> {
1964                self.inner.seek(p)
1965            }
1966        }
1967
1968        let mut buf = Vec::new();
1969        buf.extend_from_slice(b"%PDF-1.7\n");
1970        let cat_off = buf.len() as u64;
1971        buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1972        buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
1973        // Padding object large enough that the whole file far exceeds the 64 KiB
1974        // scan chunk, so an unbounded read_to_end would be clearly distinguishable.
1975        buf.extend_from_slice(b"3 0 obj\n<< /Type /Page >>\nstream\n");
1976        buf.extend(std::iter::repeat(b'x').take(200 * 1024));
1977        buf.extend_from_slice(b"\nendstream\nendobj\n");
1978        let total = buf.len();
1979        assert!(
1980            total > 64 * 1024,
1981            "fixture must exceed the scan chunk size to be meaningful"
1982        );
1983
1984        let mut r = MaxReadReader {
1985            inner: Cursor::new(buf),
1986            max_read: 0,
1987        };
1988
1989        let (offset, window) = read_object_window(&mut r, 1, 64 * 1024)
1990            .unwrap()
1991            .expect("object 1 must be locatable by bounded scan");
1992
1993        // Located at the real header offset, window holds the object's dict.
1994        assert_eq!(offset, cat_off, "header offset must be the real line start");
1995        assert!(
1996            find_byte_pattern(&window, b"/Type /Catalog").is_some(),
1997            "window must contain the catalog dict"
1998        );
1999        assert!(
2000            find_byte_pattern(&window, b"/Pages 2 0 R").is_some(),
2001            "window must contain the /Pages reference"
2002        );
2003        // Bounded: never requested more than one scan chunk / window in a single read.
2004        assert!(
2005            r.max_read <= 64 * 1024,
2006            "locate requested {} bytes in a single read (file={total}); not bounded",
2007            r.max_read
2008        );
2009    }
2010
2011    #[test]
2012    fn test_scan_page_object_refs_matches_compact_type_page() {
2013        // `/Type/Page` without a space before the value is valid PDF (ISO 32000-1
2014        // §7.3.5) and is emitted by Ghostscript / pdfTeX. The page scan must detect
2015        // it, while still excluding the compact page-tree node `/Type/Pages`.
2016        let mut buf = Vec::new();
2017        buf.extend_from_slice(b"%PDF-1.7\n");
2018        buf.extend_from_slice(b"1 0 obj\n<< /Type/Catalog /Pages 2 0 R >>\nendobj\n");
2019        buf.extend_from_slice(b"2 0 obj\n<< /Type/Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
2020        buf.extend_from_slice(b"3 0 obj\n<< /Type/Page /Parent 2 0 R >>\nendobj\n");
2021
2022        let mut r = Cursor::new(buf);
2023        let pages = scan_page_object_refs(&mut r).unwrap();
2024        assert_eq!(
2025            pages,
2026            vec![(3, 0)],
2027            "compact /Type/Page must be detected and compact /Type/Pages excluded"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_scan_and_fill_adds_missing_preserves_present() {
2033        let mut buf = Vec::new();
2034        buf.extend_from_slice(b"%PDF-1.7\n");
2035        let off1 = buf.len() as u64;
2036        buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
2037        buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages >>\nendobj\n");
2038        let off3 = buf.len() as u64;
2039        buf.extend_from_slice(b"3 0 obj\n<< >>\nendobj\n");
2040
2041        let mut table = XRefTable::new();
2042        // Pretend obj 2 was already known from the XRef stream at a different offset.
2043        table.add_entry(
2044            2,
2045            XRefEntry {
2046                offset: 99999,
2047                generation: 0,
2048                in_use: true,
2049            },
2050        );
2051
2052        let mut reader = BufReader::new(Cursor::new(buf));
2053        XRefTable::scan_and_fill_missing_objects(&mut reader, &mut table).unwrap();
2054
2055        // Missing objects 1 and 3 added at their real line-start offsets.
2056        assert_eq!(table.get_entry(1).map(|e| e.offset), Some(off1));
2057        assert_eq!(table.get_entry(3).map(|e| e.offset), Some(off3));
2058        // Already-present object 2 must NOT be overwritten by the scan.
2059        assert_eq!(table.get_entry(2).map(|e| e.offset), Some(99999));
2060    }
2061
2062    #[test]
2063    fn test_recovery_finds_objects_and_catalog_root() {
2064        let mut buf = Vec::new();
2065        buf.extend_from_slice(b"%PDF-1.7\n");
2066        let off1 = buf.len() as u64;
2067        buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
2068        let off2 = buf.len() as u64;
2069        buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
2070        let off3 = buf.len() as u64;
2071        buf.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n");
2072
2073        let mut reader = BufReader::new(Cursor::new(buf));
2074        let table =
2075            XRefTable::parse_with_recovery_options(&mut reader, &ParseOptions::default()).unwrap();
2076
2077        assert_eq!(table.get_entry(1).map(|e| e.offset), Some(off1));
2078        assert_eq!(table.get_entry(2).map(|e| e.offset), Some(off2));
2079        assert_eq!(table.get_entry(3).map(|e| e.offset), Some(off3));
2080
2081        // Root must resolve to the /Type /Catalog object (1).
2082        let root = table.trailer().and_then(|t| t.get("Root")).cloned();
2083        assert_eq!(root, Some(PdfObject::Reference(1, 0)));
2084    }
2085
2086    #[test]
2087    fn test_recovery_uses_root_from_xref_stream() {
2088        let mut buf = Vec::new();
2089        buf.extend_from_slice(b"%PDF-1.7\n");
2090        buf.extend_from_slice(b"5 0 obj\n<< /Type /Catalog /Pages 6 0 R >>\nendobj\n");
2091        buf.extend_from_slice(b"6 0 obj\n<< /Type /Pages /Count 0 >>\nendobj\n");
2092        // XRef stream object declaring /Root 5 0 R near the end of the file.
2093        buf.extend_from_slice(
2094            b"9 0 obj\n<< /Type /XRef /Root 5 0 R /Size 10 >>\nstream\n....\nendstream\nendobj\n",
2095        );
2096
2097        let mut reader = BufReader::new(Cursor::new(buf));
2098        let table =
2099            XRefTable::parse_with_recovery_options(&mut reader, &ParseOptions::default()).unwrap();
2100
2101        let root = table.trailer().and_then(|t| t.get("Root")).cloned();
2102        assert_eq!(root, Some(PdfObject::Reference(5, 0)));
2103    }
2104
2105    #[test]
2106    fn test_recovery_empty_when_no_objects() {
2107        let mut reader = BufReader::new(Cursor::new(b"%PDF-1.7\nnothing useful here\n".to_vec()));
2108        let result = XRefTable::parse_with_recovery_options(&mut reader, &ParseOptions::default());
2109        assert!(matches!(result, Err(ParseError::InvalidXRef)));
2110    }
2111
2112    #[test]
2113    fn test_parse_xref_entry() {
2114        let entry1 = XRefTable::parse_xref_entry("0000000000 65535 f ").unwrap();
2115        assert_eq!(entry1.offset, 0);
2116        assert_eq!(entry1.generation, 65535);
2117        assert!(!entry1.in_use);
2118
2119        let entry2 = XRefTable::parse_xref_entry("0000000017 00000 n ").unwrap();
2120        assert_eq!(entry2.offset, 17);
2121        assert_eq!(entry2.generation, 0);
2122        assert!(entry2.in_use);
2123    }
2124
2125    #[test]
2126    fn test_parse_xref_entry_flexible() {
2127        // Test various flexible formats
2128
2129        // Extra spaces
2130        let entry1 = XRefTable::parse_xref_entry("17   0   n").unwrap();
2131        assert_eq!(entry1.offset, 17);
2132        assert_eq!(entry1.generation, 0);
2133        assert!(entry1.in_use);
2134
2135        // Different padding
2136        let entry2 = XRefTable::parse_xref_entry("123 5 f").unwrap();
2137        assert_eq!(entry2.offset, 123);
2138        assert_eq!(entry2.generation, 5);
2139        assert!(!entry2.in_use);
2140
2141        // Missing generation (defaults to 0)
2142        let entry3 = XRefTable::parse_xref_entry("456 n").unwrap();
2143        assert_eq!(entry3.offset, 456);
2144        assert_eq!(entry3.generation, 0);
2145        assert!(entry3.in_use);
2146
2147        // Missing flag (defaults to true)
2148        let entry4 = XRefTable::parse_xref_entry("789 2").unwrap();
2149        assert_eq!(entry4.offset, 789);
2150        assert_eq!(entry4.generation, 2);
2151        assert!(entry4.in_use);
2152
2153        // Flag attached to generation
2154        let entry5 = XRefTable::parse_xref_entry("1000 0n").unwrap();
2155        assert_eq!(entry5.offset, 1000);
2156        assert_eq!(entry5.generation, 0);
2157        assert!(entry5.in_use);
2158
2159        let entry6 = XRefTable::parse_xref_entry("2000 1f").unwrap();
2160        assert_eq!(entry6.offset, 2000);
2161        assert_eq!(entry6.generation, 1);
2162        assert!(!entry6.in_use);
2163
2164        // Tabs instead of spaces
2165        let entry7 = XRefTable::parse_xref_entry("3000\t0\tn").unwrap();
2166        assert_eq!(entry7.offset, 3000);
2167        assert_eq!(entry7.generation, 0);
2168        assert!(entry7.in_use);
2169    }
2170
2171    #[test]
2172    fn test_parse_xref_entry_invalid_flag_fallback() {
2173        // Invalid flag should default to 'n' with warning
2174        let entry = XRefTable::parse_xref_entry("100 0 x").unwrap();
2175        assert_eq!(entry.offset, 100);
2176        assert_eq!(entry.generation, 0);
2177        assert!(entry.in_use); // Should default to true
2178    }
2179
2180    #[test]
2181    fn test_parse_xref_entry_malformed() {
2182        // Empty line
2183        let result = XRefTable::parse_xref_entry("");
2184        assert!(result.is_err());
2185
2186        // Non-numeric offset
2187        let result = XRefTable::parse_xref_entry("abc 0 n");
2188        assert!(result.is_err());
2189
2190        // Only whitespace
2191        let result = XRefTable::parse_xref_entry("   ");
2192        assert!(result.is_err());
2193    }
2194
2195    #[test]
2196    fn test_xref_table_new() {
2197        let table = XRefTable::new();
2198        assert!(table.entries.is_empty());
2199        assert!(table.extended_entries.is_empty());
2200        assert!(table.trailer.is_none());
2201        assert_eq!(table.xref_offset, 0);
2202    }
2203
2204    #[test]
2205    fn test_xref_table_default() {
2206        let table = XRefTable::default();
2207        assert!(table.entries.is_empty());
2208        assert!(table.extended_entries.is_empty());
2209        assert!(table.trailer.is_none());
2210    }
2211
2212    #[test]
2213    fn test_xref_entry_struct() {
2214        let entry = XRefEntry {
2215            offset: 12345,
2216            generation: 7,
2217            in_use: true,
2218        };
2219        assert_eq!(entry.offset, 12345);
2220        assert_eq!(entry.generation, 7);
2221        assert!(entry.in_use);
2222    }
2223
2224    #[test]
2225    fn test_xref_entry_equality() {
2226        let entry1 = XRefEntry {
2227            offset: 100,
2228            generation: 0,
2229            in_use: true,
2230        };
2231        let entry2 = XRefEntry {
2232            offset: 100,
2233            generation: 0,
2234            in_use: true,
2235        };
2236        assert_eq!(entry1, entry2);
2237    }
2238
2239    #[test]
2240    fn test_xref_entry_clone() {
2241        let entry = XRefEntry {
2242            offset: 999,
2243            generation: 3,
2244            in_use: false,
2245        };
2246        let cloned = entry;
2247        assert_eq!(cloned.offset, 999);
2248        assert_eq!(cloned.generation, 3);
2249        assert!(!cloned.in_use);
2250    }
2251
2252    #[test]
2253    fn test_xref_entry_ext() {
2254        let ext_entry = XRefEntryExt {
2255            basic: XRefEntry {
2256                offset: 500,
2257                generation: 0,
2258                in_use: true,
2259            },
2260            compressed_info: Some((10, 5)),
2261        };
2262        assert_eq!(ext_entry.basic.offset, 500);
2263        assert_eq!(ext_entry.compressed_info, Some((10, 5)));
2264    }
2265
2266    #[test]
2267    fn test_xref_entry_ext_no_compression() {
2268        let ext_entry = XRefEntryExt {
2269            basic: XRefEntry {
2270                offset: 1000,
2271                generation: 1,
2272                in_use: true,
2273            },
2274            compressed_info: None,
2275        };
2276        assert!(ext_entry.compressed_info.is_none());
2277    }
2278
2279    #[test]
2280    fn test_add_entry() {
2281        let mut table = XRefTable::new();
2282        table.add_entry(
2283            5,
2284            XRefEntry {
2285                offset: 1000,
2286                generation: 0,
2287                in_use: true,
2288            },
2289        );
2290        assert_eq!(table.entries.len(), 1);
2291        assert!(table.entries.contains_key(&5));
2292    }
2293
2294    #[test]
2295    fn test_get_entry() {
2296        let mut table = XRefTable::new();
2297        let entry = XRefEntry {
2298            offset: 2000,
2299            generation: 1,
2300            in_use: true,
2301        };
2302        table.add_entry(10, entry);
2303
2304        let retrieved = table.get_entry(10);
2305        assert!(retrieved.is_some());
2306        assert_eq!(retrieved.unwrap().offset, 2000);
2307
2308        let missing = table.get_entry(999);
2309        assert!(missing.is_none());
2310    }
2311
2312    #[test]
2313    fn test_set_trailer() {
2314        let mut table = XRefTable::new();
2315        let mut trailer = PdfDictionary::new();
2316        trailer.insert("Size".to_string(), PdfObject::Integer(10));
2317
2318        table.set_trailer(trailer.clone());
2319        assert!(table.trailer.is_some());
2320        assert_eq!(
2321            table.trailer().unwrap().get("Size"),
2322            Some(&PdfObject::Integer(10))
2323        );
2324    }
2325
2326    #[test]
2327    fn test_parse_xref_entry_invalid() {
2328        // Too short
2329        let result = XRefTable::parse_xref_entry("0000000000 65535");
2330        assert!(result.is_ok()); // Now handled by flexible parsing
2331
2332        // Invalid format (non-numeric offset)
2333        let result = XRefTable::parse_xref_entry("not_a_number 65535 f ");
2334        assert!(result.is_err());
2335
2336        // Invalid flag (now accepted with warning, defaults to 'n')
2337        let result = XRefTable::parse_xref_entry("0000000000 65535 x ");
2338        assert!(result.is_ok()); // Flexible parsing accepts this
2339        assert!(result.unwrap().in_use); // Should default to true
2340    }
2341
2342    #[test]
2343    fn test_parse_xref_entry_various_offsets() {
2344        // Small offset
2345        let entry = XRefTable::parse_xref_entry("0000000001 00000 n ").unwrap();
2346        assert_eq!(entry.offset, 1);
2347
2348        // Large offset
2349        let entry = XRefTable::parse_xref_entry("9999999999 00000 n ").unwrap();
2350        assert_eq!(entry.offset, 9999999999);
2351
2352        // Max generation
2353        let entry = XRefTable::parse_xref_entry("0000000000 65535 f ").unwrap();
2354        assert_eq!(entry.generation, 65535);
2355    }
2356
2357    #[test]
2358    fn test_add_extended_entry() {
2359        let mut table = XRefTable::new();
2360        let ext_entry = XRefEntryExt {
2361            basic: XRefEntry {
2362                offset: 0,
2363                generation: 0,
2364                in_use: true,
2365            },
2366            compressed_info: Some((5, 10)),
2367        };
2368
2369        table.add_extended_entry(15, ext_entry);
2370        assert_eq!(table.extended_entries.len(), 1);
2371        assert!(table.extended_entries.contains_key(&15));
2372    }
2373
2374    #[test]
2375    fn test_get_extended_entry() {
2376        let mut table = XRefTable::new();
2377        let ext_entry = XRefEntryExt {
2378            basic: XRefEntry {
2379                offset: 0,
2380                generation: 0,
2381                in_use: true,
2382            },
2383            compressed_info: Some((20, 3)),
2384        };
2385
2386        table.add_extended_entry(7, ext_entry);
2387
2388        let retrieved = table.get_extended_entry(7);
2389        assert!(retrieved.is_some());
2390        assert_eq!(retrieved.unwrap().compressed_info, Some((20, 3)));
2391    }
2392
2393    #[test]
2394    fn test_xref_offset() {
2395        let mut table = XRefTable::new();
2396        assert_eq!(table.xref_offset(), 0);
2397
2398        table.xref_offset = 12345;
2399        assert_eq!(table.xref_offset(), 12345);
2400    }
2401
2402    #[test]
2403    fn test_find_xref_offset_simple() {
2404        let pdf_data = b"startxref\n12345\n%%EOF";
2405        let cursor = Cursor::new(pdf_data.to_vec());
2406        let mut reader = BufReader::new(cursor);
2407
2408        let offset = XRefTable::find_xref_offset(&mut reader).unwrap();
2409        assert_eq!(offset, 12345);
2410    }
2411
2412    #[test]
2413    fn test_find_xref_offset_with_spaces() {
2414        let pdf_data = b"startxref  \n  12345  \n%%EOF";
2415        let cursor = Cursor::new(pdf_data.to_vec());
2416        let mut reader = BufReader::new(cursor);
2417
2418        let offset = XRefTable::find_xref_offset(&mut reader).unwrap();
2419        assert_eq!(offset, 12345);
2420    }
2421
2422    #[test]
2423    fn test_find_xref_offset_missing() {
2424        let pdf_data = b"no startxref here";
2425        let cursor = Cursor::new(pdf_data.to_vec());
2426        let mut reader = BufReader::new(cursor);
2427
2428        let result = XRefTable::find_xref_offset(&mut reader);
2429        assert!(result.is_err());
2430    }
2431
2432    #[test]
2433    fn test_trailer_getter() {
2434        let mut table = XRefTable::new();
2435        assert!(table.trailer().is_none());
2436
2437        let trailer = PdfDictionary::new();
2438        table.set_trailer(trailer);
2439        assert!(table.trailer().is_some());
2440    }
2441
2442    #[test]
2443    fn test_xref_table_clone() {
2444        let mut table = XRefTable::new();
2445        table.add_entry(
2446            1,
2447            XRefEntry {
2448                offset: 100,
2449                generation: 0,
2450                in_use: true,
2451            },
2452        );
2453        table.xref_offset = 5000;
2454
2455        let cloned = table.clone();
2456        assert_eq!(cloned.entries.len(), 1);
2457        assert_eq!(cloned.xref_offset, 5000);
2458    }
2459
2460    #[test]
2461    fn test_parse_obj_header() {
2462        // Valid headers
2463        assert_eq!(parse_obj_header_bytes(b"1 0 obj"), Some((1, 0)));
2464        assert_eq!(parse_obj_header_bytes(b"123 5 obj"), Some((123, 5)));
2465        assert_eq!(parse_obj_header_bytes(b"  42   3   obj  "), Some((42, 3)));
2466
2467        // Invalid headers
2468        assert_eq!(parse_obj_header_bytes(b"1 obj"), None);
2469        assert_eq!(parse_obj_header_bytes(b"abc 0 obj"), None);
2470        assert_eq!(parse_obj_header_bytes(b"1 0 object"), None);
2471        assert_eq!(parse_obj_header_bytes(b""), None);
2472    }
2473
2474    #[test]
2475    fn test_xref_recovery_parsing() {
2476        // Create a mock PDF content with objects but no valid xref
2477        let pdf_content =
2478            b"1 0 obj\n<< /Type /Catalog >>\nendobj\n2 0 obj\n<< /Type /Page >>\nendobj\n";
2479        let mut reader = BufReader::new(Cursor::new(pdf_content));
2480
2481        let table = XRefTable::parse_with_recovery(&mut reader).unwrap();
2482
2483        // Should find both objects
2484        assert_eq!(table.len(), 2);
2485        assert!(table.get_entry(1).is_some());
2486        assert!(table.get_entry(2).is_some());
2487
2488        // Both should be marked as in-use
2489        assert!(table.get_entry(1).unwrap().in_use);
2490        assert!(table.get_entry(2).unwrap().in_use);
2491    }
2492
2493    #[test]
2494    fn test_xref_recovery_no_objects() {
2495        // Create content with no valid objects
2496        let pdf_content = b"This is not a PDF file\nNo objects here\n";
2497        let mut reader = BufReader::new(Cursor::new(pdf_content));
2498
2499        let result = XRefTable::parse_with_recovery(&mut reader);
2500        assert!(result.is_err());
2501    }
2502
2503    #[test]
2504    fn test_offset_validation() {
2505        let pdf_data = b"small file";
2506        let mut reader = BufReader::new(Cursor::new(pdf_data));
2507
2508        // Valid offset
2509        assert!(XRefTable::validate_offset(&mut reader, 5).is_ok());
2510
2511        // Invalid offset (beyond file size)
2512        assert!(XRefTable::validate_offset(&mut reader, 100).is_err());
2513
2514        // Offset at end of file
2515        assert!(XRefTable::validate_offset(&mut reader, 10).is_err());
2516    }
2517
2518    #[test]
2519    fn test_xref_parse_with_fallback() {
2520        // Issue #374: a PDF with no xref structure at all is reconstructed by
2521        // scanning object headers when recovery is allowed
2522        // (max_recovery_attempts > 0, the case for default options).
2523        let pdf_content =
2524            b"1 0 obj\n<< /Type /Catalog >>\nendobj\n2 0 obj\n<< /Type /Page >>\nendobj\n";
2525
2526        // Default options allow recovery: the table is rebuilt from the scan.
2527        let mut reader = BufReader::new(Cursor::new(pdf_content.to_vec()));
2528        let table = XRefTable::parse(&mut reader).expect("recovery rebuilds xref from object scan");
2529        assert!(table.get_entry(1).is_some(), "object 1 indexed by scan");
2530        assert!(table.get_entry(2).is_some(), "object 2 indexed by scan");
2531
2532        // strict() disables recovery (max_recovery_attempts == 0): a missing
2533        // xref must still fail loudly with InvalidXRef.
2534        let mut reader = BufReader::new(Cursor::new(pdf_content.to_vec()));
2535        match XRefTable::parse_with_options(&mut reader, &ParseOptions::strict()) {
2536            Err(ParseError::InvalidXRef) => {}
2537            Ok(_) => panic!("strict() must fail on missing xref, not recover"),
2538            Err(other) => panic!("strict() must fail with InvalidXRef, got: {other:?}"),
2539        }
2540    }
2541
2542    #[test]
2543    fn test_xref_entry_creation() {
2544        let entry = XRefEntry {
2545            offset: 1234,
2546            generation: 5,
2547            in_use: true,
2548        };
2549
2550        assert_eq!(entry.offset, 1234);
2551        assert_eq!(entry.generation, 5);
2552        assert!(entry.in_use);
2553    }
2554
2555    #[test]
2556    fn test_xref_entry_ext_creation() {
2557        let basic = XRefEntry {
2558            offset: 5000,
2559            generation: 0,
2560            in_use: true,
2561        };
2562
2563        let ext = XRefEntryExt {
2564            basic: basic.clone(),
2565            compressed_info: Some((10, 3)),
2566        };
2567
2568        assert_eq!(ext.basic.offset, 5000);
2569        assert_eq!(ext.compressed_info, Some((10, 3)));
2570    }
2571
2572    #[test]
2573    fn test_xref_table_new_advanced() {
2574        let table = XRefTable::new();
2575        assert_eq!(table.entries.len(), 0);
2576        assert_eq!(table.extended_entries.len(), 0);
2577        assert!(table.trailer.is_none());
2578        assert_eq!(table.xref_offset, 0);
2579    }
2580
2581    #[test]
2582    fn test_xref_table_default_advanced() {
2583        let table = XRefTable::default();
2584        assert_eq!(table.entries.len(), 0);
2585        assert!(table.trailer.is_none());
2586    }
2587
2588    #[test]
2589    fn test_xref_table_add_entry() {
2590        let mut table = XRefTable::new();
2591
2592        let entry1 = XRefEntry {
2593            offset: 100,
2594            generation: 0,
2595            in_use: true,
2596        };
2597        table.add_entry(1, entry1);
2598        let entry2 = XRefEntry {
2599            offset: 200,
2600            generation: 1,
2601            in_use: false,
2602        };
2603        table.add_entry(2, entry2);
2604
2605        assert_eq!(table.len(), 2);
2606
2607        let entry1 = table.get_entry(1).unwrap();
2608        assert_eq!(entry1.offset, 100);
2609        assert_eq!(entry1.generation, 0);
2610        assert!(entry1.in_use);
2611
2612        let entry2 = table.get_entry(2).unwrap();
2613        assert_eq!(entry2.offset, 200);
2614        assert_eq!(entry2.generation, 1);
2615        assert!(!entry2.in_use);
2616    }
2617
2618    #[test]
2619    fn test_xref_table_add_extended_entry() {
2620        let mut table = XRefTable::new();
2621
2622        let basic_entry = XRefEntry {
2623            offset: 0,
2624            generation: 0,
2625            in_use: true,
2626        };
2627
2628        let extended_entry = XRefEntryExt {
2629            basic: basic_entry,
2630            compressed_info: Some((10, 2)),
2631        };
2632
2633        table.add_extended_entry(5, extended_entry);
2634
2635        // Check extended entry
2636        let ext = table.get_extended_entry(5);
2637        assert!(ext.is_some());
2638        if let Some(ext) = ext {
2639            assert_eq!(ext.compressed_info, Some((10, 2)));
2640        }
2641
2642        assert!(table.is_compressed(5));
2643    }
2644
2645    #[test]
2646    fn test_xref_table_get_nonexistent() {
2647        let table = XRefTable::new();
2648        assert!(table.get_entry(999).is_none());
2649        assert!(table.get_extended_entry(999).is_none());
2650    }
2651
2652    #[test]
2653    fn test_xref_table_update_entry() {
2654        let mut table = XRefTable::new();
2655
2656        // Add initial entry
2657        let entry1 = XRefEntry {
2658            offset: 100,
2659            generation: 0,
2660            in_use: true,
2661        };
2662        table.add_entry(1, entry1);
2663
2664        // Update it
2665        let entry2 = XRefEntry {
2666            offset: 200,
2667            generation: 1,
2668            in_use: false,
2669        };
2670        table.add_entry(1, entry2);
2671
2672        // Should have updated
2673        let entry = table.get_entry(1).unwrap();
2674        assert_eq!(entry.offset, 200);
2675        assert_eq!(entry.generation, 1);
2676        assert!(!entry.in_use);
2677    }
2678
2679    #[test]
2680    fn test_xref_table_set_trailer() {
2681        let mut table = XRefTable::new();
2682        assert!(table.trailer.is_none());
2683
2684        let mut trailer = PdfDictionary::new();
2685        trailer.insert("Size".to_string(), PdfObject::Integer(10));
2686
2687        table.set_trailer(trailer.clone());
2688        assert!(table.trailer.is_some());
2689        assert_eq!(table.trailer(), Some(&trailer));
2690    }
2691
2692    #[test]
2693    fn test_xref_table_offset() {
2694        let table = XRefTable::new();
2695        assert_eq!(table.xref_offset(), 0);
2696    }
2697
2698    #[test]
2699    fn test_parse_xref_entry_invalid_static() {
2700        let invalid_lines = vec![
2701            "not a valid entry".to_string(),
2702            "12345 abcde n".to_string(), // Non-numeric generation
2703        ];
2704
2705        for line in invalid_lines {
2706            let result = XRefTable::parse_xref_entry(&line);
2707            assert!(result.is_err());
2708        }
2709
2710        // This line is now accepted by flexible parsing (missing flag defaults to 'n')
2711        let result = XRefTable::parse_xref_entry("12345 00000");
2712        assert!(result.is_ok());
2713        let entry = result.unwrap();
2714        assert_eq!(entry.offset, 12345);
2715        assert_eq!(entry.generation, 0);
2716        assert!(entry.in_use); // Defaults to true
2717    }
2718
2719    #[test]
2720    fn test_xref_entry_operations() {
2721        let mut table = XRefTable::new();
2722
2723        // Add entries
2724        let entry1 = XRefEntry {
2725            offset: 1234,
2726            generation: 5,
2727            in_use: true,
2728        };
2729
2730        let entry2 = XRefEntry {
2731            offset: 5678,
2732            generation: 10,
2733            in_use: false,
2734        };
2735
2736        table.add_entry(1, entry1);
2737        table.add_entry(2, entry2);
2738
2739        assert_eq!(table.len(), 2);
2740
2741        let retrieved1 = table.get_entry(1).unwrap();
2742        assert_eq!(retrieved1.offset, 1234);
2743        assert_eq!(retrieved1.generation, 5);
2744        assert!(retrieved1.in_use);
2745
2746        let retrieved2 = table.get_entry(2).unwrap();
2747        assert_eq!(retrieved2.offset, 5678);
2748        assert_eq!(retrieved2.generation, 10);
2749        assert!(!retrieved2.in_use);
2750    }
2751
2752    #[test]
2753    fn test_parse_xref_with_comments() {
2754        let pdf_content = b"%PDF-1.4\n\
27551 0 obj\n<< /Type /Catalog >>\nendobj\n\
2756xref\n\
2757% This is a comment\n\
27580 2\n\
27590000000000 65535 f \n\
27600000000015 00000 n \n\
2761% Another comment\n\
2762trailer\n\
2763<< /Size 2 /Root 1 0 R >>\n\
2764startxref\n\
276545\n\
2766%%EOF";
2767
2768        let mut reader = BufReader::new(Cursor::new(pdf_content));
2769        reader.seek(SeekFrom::Start(45)).unwrap(); // Position of 'xref'
2770
2771        let result = XRefTable::parse(&mut reader);
2772        assert!(result.is_ok());
2773        let table = result.unwrap();
2774        assert_eq!(table.len(), 2);
2775    }
2776
2777    #[test]
2778    fn test_parse_multiple_xref_sections() {
2779        let pdf_content = b"%PDF-1.4\n\
27801 0 obj\n<< /Type /Catalog >>\nendobj\n\
27812 0 obj\n<< /Type /Page >>\nendobj\n\
2782xref\n\
27830 2\n\
27840000000000 65535 f \n\
27850000000015 00000 n \n\
27865 2\n\
27870000000100 00000 n \n\
27880000000200 00000 n \n\
2789trailer\n\
2790<< /Size 7 /Root 1 0 R >>\n\
2791startxref\n\
279278\n\
2793%%EOF";
2794
2795        let mut reader = BufReader::new(Cursor::new(pdf_content));
2796        reader.seek(SeekFrom::Start(78)).unwrap(); // Position of 'xref'
2797
2798        let result = XRefTable::parse(&mut reader);
2799        assert!(result.is_ok());
2800        let table = result.unwrap();
2801        // Should have entries 0, 1, 5, 6
2802        assert_eq!(table.len(), 4);
2803        assert!(table.get_entry(0).is_some());
2804        assert!(table.get_entry(1).is_some());
2805        assert!(table.get_entry(5).is_some());
2806        assert!(table.get_entry(6).is_some());
2807    }
2808
2809    #[test]
2810    fn test_parse_xref_with_prev() {
2811        // Test incremental update with Prev pointer
2812        let pdf_content = b"%PDF-1.4\n\
2813% First xref at 15\n\
2814xref\n\
28150 2\n\
28160000000000 65535 f \n\
28170000000100 00000 n \n\
2818trailer\n\
2819<< /Size 2 >>\n\
2820% Second xref at 100\n\
2821xref\n\
28222 1\n\
28230000000200 00000 n \n\
2824trailer\n\
2825<< /Size 3 /Prev 15 >>\n\
2826startxref\n\
2827100\n\
2828%%EOF";
2829
2830        let mut reader = BufReader::new(Cursor::new(pdf_content));
2831        let options = ParseOptions {
2832            lenient_syntax: true,
2833            ..Default::default()
2834        };
2835
2836        let result = XRefTable::parse_with_options(&mut reader, &options);
2837        // The test might fail due to seeking issues, but structure is tested
2838        assert!(result.is_ok() || result.is_err());
2839    }
2840
2841    #[test]
2842    fn test_invalid_xref_format() {
2843        let pdf_content = b"xref\ninvalid content\ntrailer";
2844        let mut reader = BufReader::new(Cursor::new(pdf_content));
2845
2846        let result = XRefTable::parse(&mut reader);
2847        assert!(result.is_err());
2848    }
2849
2850    #[test]
2851    fn test_xref_entry_overflow() {
2852        let mut table = XRefTable::new();
2853
2854        // Test with maximum values
2855        let entry = XRefEntry {
2856            offset: u64::MAX,
2857            generation: u16::MAX,
2858            in_use: true,
2859        };
2860        table.add_entry(u32::MAX, entry);
2861
2862        let entry = table.get_entry(u32::MAX).unwrap();
2863        assert_eq!(entry.offset, u64::MAX);
2864        assert_eq!(entry.generation, u16::MAX);
2865    }
2866
2867    #[test]
2868    fn test_xref_table_operations() {
2869        let mut table = XRefTable::new();
2870
2871        // Add some entries using correct API
2872        let entry1 = XRefEntry {
2873            offset: 100,
2874            generation: 0,
2875            in_use: true,
2876        };
2877
2878        let entry2 = XRefEntry {
2879            offset: 200,
2880            generation: 0,
2881            in_use: true,
2882        };
2883
2884        table.add_entry(1, entry1);
2885        table.add_entry(2, entry2);
2886
2887        assert_eq!(table.len(), 2);
2888        assert!(table.get_entry(1).is_some());
2889        assert!(table.get_entry(2).is_some());
2890        assert!(table.get_entry(3).is_none());
2891    }
2892
2893    #[test]
2894    fn test_xref_table_merge() {
2895        let mut table1 = XRefTable::new();
2896        let entry1 = XRefEntry {
2897            offset: 100,
2898            generation: 0,
2899            in_use: true,
2900        };
2901        table1.add_entry(1, entry1);
2902        let entry2 = XRefEntry {
2903            offset: 200,
2904            generation: 0,
2905            in_use: true,
2906        };
2907        table1.add_entry(2, entry2);
2908
2909        let mut table2 = XRefTable::new();
2910        let entry3 = XRefEntry {
2911            offset: 250,
2912            generation: 1,
2913            in_use: true,
2914        }; // Update entry 2
2915        table2.add_entry(2, entry3);
2916        let entry4 = XRefEntry {
2917            offset: 300,
2918            generation: 0,
2919            in_use: true,
2920        }; // New entry
2921        table2.add_entry(3, entry4);
2922
2923        // Manual merge simulation since merge method doesn't exist
2924        // Copy entries from table2 to table1
2925        for i in 2..=3 {
2926            if let Some(entry) = table2.get_entry(i) {
2927                table1.add_entry(
2928                    i,
2929                    XRefEntry {
2930                        offset: entry.offset,
2931                        generation: entry.generation,
2932                        in_use: entry.in_use,
2933                    },
2934                );
2935            }
2936        }
2937
2938        assert_eq!(table1.len(), 3);
2939
2940        // Entry 2 should be updated
2941        let entry2 = table1.get_entry(2).unwrap();
2942        assert_eq!(entry2.offset, 250);
2943        assert_eq!(entry2.generation, 1);
2944
2945        // Entry 3 should be added
2946        assert!(table1.get_entry(3).is_some());
2947    }
2948
2949    #[test]
2950    fn test_xref_recovery_with_stream() {
2951        let pdf_content = b"1 0 obj\n<< /Type /ObjStm /N 2 /First 10 >>\nstream\n12345678901 0 2 0\nendstream\nendobj\n";
2952        let mut reader = BufReader::new(Cursor::new(pdf_content));
2953
2954        let result = XRefTable::parse_with_recovery(&mut reader);
2955        // Should find the object stream
2956        assert!(result.is_ok() || result.is_err());
2957    }
2958
2959    #[test]
2960    fn test_xref_entry_equality_advanced() {
2961        let entry1 = XRefEntry {
2962            offset: 100,
2963            generation: 0,
2964            in_use: true,
2965        };
2966
2967        let entry2 = XRefEntry {
2968            offset: 100,
2969            generation: 0,
2970            in_use: true,
2971        };
2972
2973        let entry3 = XRefEntry {
2974            offset: 200,
2975            generation: 0,
2976            in_use: true,
2977        };
2978
2979        assert_eq!(entry1, entry2);
2980        assert_ne!(entry1, entry3);
2981    }
2982
2983    #[test]
2984    fn test_parse_options_effect() {
2985        let pdf_content = b"xref 0 1 invalid";
2986        let mut reader = BufReader::new(Cursor::new(pdf_content));
2987
2988        // Strict parsing should fail
2989        let strict_options = ParseOptions {
2990            lenient_syntax: false,
2991            ..Default::default()
2992        };
2993        let result = XRefTable::parse_with_options(&mut reader, &strict_options);
2994        assert!(result.is_err());
2995
2996        // Lenient parsing might recover
2997        reader.seek(SeekFrom::Start(0)).unwrap();
2998        let lenient_options = ParseOptions {
2999            lenient_syntax: true,
3000            ..Default::default()
3001        };
3002        let result = XRefTable::parse_with_options(&mut reader, &lenient_options);
3003        // May still fail but tests the option path
3004        assert!(result.is_err() || result.is_ok());
3005    }
3006
3007    #[test]
3008    fn test_circular_reference_detection() {
3009        // Test circular reference detection (lines 117-121)
3010        let pdf_content = b"%PDF-1.4\n\
3011xref\n\
30120 1\n\
30130000000000 65535 f \n\
3014trailer\n\
3015<< /Size 1 /Prev 10 >>\n\
3016startxref\n\
301710\n\
3018%%EOF";
3019
3020        let mut reader = BufReader::new(Cursor::new(pdf_content));
3021
3022        // This should detect the circular reference (Prev points to itself)
3023        let result = XRefTable::parse_with_incremental_updates(&mut reader);
3024        // Should handle circular reference gracefully
3025        assert!(result.is_ok() || result.is_err());
3026    }
3027
3028    #[test]
3029    fn test_linearized_xref_detection() {
3030        // Test finding linearized xref (lines 177-178)
3031        let pdf_content = b"%PDF-1.4\n\
30321 0 obj\n\
3033<< /Linearized 1 /L 1234 /H [100 200] /O 5 /E 500 /N 10 /T 600 >>\n\
3034endobj\n\
3035xref\n\
30360 2\n\
30370000000000 65535 f \n\
30380000000009 00000 n \n\
3039trailer\n\
3040<< /Size 2 >>\n\
3041startxref\n\
304263\n\
3043%%EOF";
3044
3045        let mut reader = BufReader::new(Cursor::new(pdf_content));
3046
3047        // Test finding linearized xref
3048        let result = XRefTable::find_linearized_xref(&mut reader);
3049        assert!(result.is_ok());
3050
3051        // The actual position of "xref" in the content is at byte 90
3052        // Count: "%PDF-1.4\n" (9) + "1 0 obj\n" (8) + "<< /Linearized ... >>\n" (63) + "endobj\n" (7) + "xref" starts at 87
3053        let xref_pos = result.unwrap();
3054        assert_eq!(
3055            xref_pos, 90,
3056            "Expected xref at position 90, got {}",
3057            xref_pos
3058        );
3059    }
3060
3061    #[test]
3062    fn test_xref_stream_parsing() {
3063        // Test parsing xref streams (lines 240-243)
3064
3065        let pdf_content = b"%PDF-1.5\n\
30661 0 obj\n\
3067<< /Type /XRef /Size 3 /W [1 2 1] /Length 12 >>\n\
3068stream\n\
3069\x00\x00\x00\x00\
3070\x01\x00\x10\x00\
3071\x01\x00\x20\x00\
3072endstream\n\
3073endobj\n\
3074startxref\n\
30759\n\
3076%%EOF";
3077
3078        let mut reader = BufReader::new(Cursor::new(pdf_content));
3079        reader.seek(SeekFrom::Start(9)).unwrap();
3080
3081        // This tests the xref stream parsing path
3082        let result = XRefTable::parse(&mut reader);
3083        // XRef streams are more complex and may fail in this simple test
3084        assert!(result.is_err() || result.is_ok());
3085    }
3086
3087    #[test]
3088    fn test_xref_validation_max_object_exceeds_size() {
3089        // Test validation where max object number exceeds Size (lines 446-449)
3090        let pdf_content = b"%PDF-1.4\n\
3091xref\n\
30920 1\n\
30930000000000 65535 f \n\
309410 1\n\
30950000000100 00000 n \n\
3096trailer\n\
3097<< /Size 5 /Root 1 0 R >>\n\
3098startxref\n\
30999\n\
3100%%EOF";
3101
3102        let mut reader = BufReader::new(Cursor::new(pdf_content));
3103        reader.seek(SeekFrom::Start(9)).unwrap();
3104
3105        // This should fail validation because object 10 > Size 5
3106        let result = XRefTable::parse(&mut reader);
3107        assert!(result.is_err());
3108    }
3109
3110    #[test]
3111    fn test_parse_with_options_lenient_vs_strict() {
3112        // Test different parsing options behavior
3113        let pdf_content = b"%PDF-1.4\n\
3114xref\n\
31150 2\n\
31160000000000 65535 f \n\
31170000000015 00000 n \n\
3118trailer\n\
3119<< /Size 2 >>\n\
3120startxref\n\
31219\n\
3122%%EOF";
3123
3124        let mut reader = BufReader::new(Cursor::new(pdf_content));
3125
3126        // Test with strict options
3127        let strict_options = ParseOptions {
3128            lenient_syntax: false,
3129            recover_from_stream_errors: false,
3130            ..Default::default()
3131        };
3132        reader.seek(SeekFrom::Start(9)).unwrap();
3133        let strict_result = XRefTable::parse_with_options(&mut reader, &strict_options);
3134
3135        // Test with lenient options
3136        let lenient_options = ParseOptions {
3137            lenient_syntax: true,
3138            recover_from_stream_errors: true,
3139            ..Default::default()
3140        };
3141        reader.seek(SeekFrom::Start(9)).unwrap();
3142        let lenient_result = XRefTable::parse_with_options(&mut reader, &lenient_options);
3143
3144        // Both should succeed with valid PDF
3145        assert!(strict_result.is_ok());
3146        assert!(lenient_result.is_ok());
3147    }
3148
3149    #[test]
3150    fn test_xref_entry_with_attached_flag() {
3151        // Test parsing xref entries with flag attached to generation (e.g., "0n")
3152        let entry1 = XRefTable::parse_xref_entry("12345 0n");
3153        assert!(entry1.is_ok());
3154        let entry1 = entry1.unwrap();
3155        assert_eq!(entry1.offset, 12345);
3156        assert_eq!(entry1.generation, 0);
3157        assert!(entry1.in_use);
3158
3159        let entry2 = XRefTable::parse_xref_entry("54321 1f");
3160        assert!(entry2.is_ok());
3161        let entry2 = entry2.unwrap();
3162        assert_eq!(entry2.offset, 54321);
3163        assert_eq!(entry2.generation, 1);
3164        assert!(!entry2.in_use);
3165    }
3166
3167    #[test]
3168    fn test_find_xref_offset_edge_cases() {
3169        // Test finding xref offset in various formats
3170        use std::io::{BufReader, Cursor};
3171
3172        // With extra whitespace
3173        let content = b"garbage\nstartxref  \n  123  \n%%EOF";
3174        let mut reader = BufReader::new(Cursor::new(content));
3175        let result = XRefTable::find_xref_offset(&mut reader);
3176        assert_eq!(result.unwrap(), 123);
3177
3178        // At the very end
3179        let content = b"startxref\n999\n%%EOF";
3180        let mut reader = BufReader::new(Cursor::new(content));
3181        let result = XRefTable::find_xref_offset(&mut reader);
3182        assert_eq!(result.unwrap(), 999);
3183
3184        // Missing %%EOF (should still work)
3185        let content = b"startxref\n456";
3186        let mut reader = BufReader::new(Cursor::new(content));
3187        let result = XRefTable::find_xref_offset(&mut reader);
3188        // This might fail without %%EOF marker, adjust expectation
3189        assert!(result.is_ok() || result.is_err());
3190
3191        // Missing startxref
3192        let content = b"some content\n%%EOF";
3193        let mut reader = BufReader::new(Cursor::new(content));
3194        let result = XRefTable::find_xref_offset(&mut reader);
3195        assert!(result.is_err());
3196    }
3197
3198    #[test]
3199    fn test_xref_subsection_incomplete() {
3200        // Test handling of incomplete xref subsections
3201        let pdf_content = b"%PDF-1.4\n\
3202xref\n\
32030 5\n\
32040000000000 65535 f \n\
32050000000015 00000 n \n\
3206trailer\n\
3207<< /Size 5 >>\n\
3208startxref\n\
32099\n\
3210%%EOF";
3211
3212        let mut reader = BufReader::new(Cursor::new(pdf_content));
3213        reader.seek(SeekFrom::Start(9)).unwrap();
3214
3215        // This declares 5 entries but only provides 2
3216        let result = XRefTable::parse(&mut reader);
3217        // Should handle incomplete subsection
3218        assert!(result.is_err() || result.is_ok());
3219    }
3220}
3221
3222type EncryptAndId = (
3223    Option<super::objects::PdfObject>,
3224    Option<super::objects::PdfObject>,
3225);
3226
3227/// Extract `/Encrypt` and `/ID` from the original trailer found in a bounded
3228/// file tail (Issue #374), covering BOTH classic trailers and cross-reference
3229/// streams.
3230///
3231/// XRef reconstruction builds a synthetic trailer; without carrying the
3232/// original `/Encrypt` forward, an encrypted document whose xref had to be
3233/// rebuilt would be treated as unencrypted (fail-open). `/ID` is preserved too
3234/// because the standard security handler mixes it into the encryption key.
3235///
3236/// Both branches parse with the real object parser (binary-safe: handles
3237/// hex/literal strings, arrays, refs) and honour the caller's `ParseOptions`
3238/// so lenient tokenizing (e.g. a stray byte before `<<`) is applied when the
3239/// caller asked for it — not silently reverted to strict.
3240///
3241/// 1. Classic `trailer` keyword (PDF ≤1.4 and hybrid files).
3242/// 2. Cross-reference stream (PDF 1.5+): `/Encrypt` lives in the xref-stream
3243///    object's own dict, which is uncompressed text even when the stream data
3244///    is filtered, so it can be parsed straight from the tail.
3245fn extract_encrypt_and_id_from_trailer(tail: &[u8], options: &ParseOptions) -> EncryptAndId {
3246    // 1) Classic trailer.
3247    if let Some(pos) = rfind_byte_pattern(tail, b"trailer") {
3248        let after = &tail[pos + b"trailer".len()..];
3249        let mut lexer =
3250            super::lexer::Lexer::new_with_options(std::io::Cursor::new(after), options.clone());
3251        if let Ok(super::objects::PdfObject::Dictionary(dict)) =
3252            super::objects::PdfObject::parse_with_options(&mut lexer, options)
3253        {
3254            let encrypt = dict.get("Encrypt").cloned();
3255            let id = dict.get("ID").cloned();
3256            if encrypt.is_some() || id.is_some() {
3257                return (encrypt, id);
3258            }
3259        }
3260    }
3261
3262    // 2) Cross-reference stream dict.
3263    extract_encrypt_and_id_from_xref_stream(tail, options)
3264}
3265
3266/// Extract `/Encrypt` and `/ID` from a cross-reference stream object's dict in
3267/// the tail (Issue #374, fail-safe for PDF 1.5+ encrypted files).
3268///
3269/// Anchors on the xref stream object header (`N G obj`) that precedes
3270/// `/Type /XRef`, then parses the dictionary that opens after it — truncating
3271/// the region at the `stream` keyword so the parser sees a plain dictionary
3272/// (never attempting to consume the filtered stream body).
3273fn extract_encrypt_and_id_from_xref_stream(tail: &[u8], options: &ParseOptions) -> EncryptAndId {
3274    let type_pos = match rfind_byte_pattern(tail, b"/Type/XRef")
3275        .or_else(|| rfind_byte_pattern(tail, b"/Type /XRef"))
3276    {
3277        Some(p) => p,
3278        None => return (None, None),
3279    };
3280
3281    // Anchor on the object header so a nested `<<` before `/Type` can't be
3282    // mistaken for the dict opening.
3283    let Some(obj_pos) = rfind_byte_pattern(&tail[..type_pos], b"obj") else {
3284        return (None, None);
3285    };
3286    let Some(rel) = find_byte_pattern(&tail[obj_pos..type_pos], b"<<") else {
3287        return (None, None);
3288    };
3289    let dict_start = obj_pos + rel;
3290
3291    // Parse ONLY the inner dictionary (up to `>>`). We must not let the object
3292    // parser attempt the following `stream` body: its /Length may be an
3293    // indirect reference or otherwise unparseable in a recovery scenario, and a
3294    // body-parse failure would discard the dictionary (and thus /Encrypt) —
3295    // reopening the fail-open hole. Truncating at a raw `stream` substring is
3296    // also wrong, since a value before /Encrypt (e.g. `/Producer (streamlined)`)
3297    // can contain those bytes. The inner-dict parser respects string/nesting
3298    // boundaries, so neither problem applies.
3299    let region = &tail[dict_start..];
3300    let mut lexer =
3301        super::lexer::Lexer::new_with_options(std::io::Cursor::new(region), options.clone());
3302    // Consume the opening `<<` before parsing the dictionary body.
3303    if !matches!(lexer.next_token(), Ok(super::lexer::Token::DictStart)) {
3304        return (None, None);
3305    }
3306    match super::objects::PdfObject::parse_dictionary_inner_with_options(&mut lexer, options) {
3307        Ok(dict) => (dict.get("Encrypt").cloned(), dict.get("ID").cloned()),
3308        Err(_) => (None, None),
3309    }
3310}
3311
3312/// Extract Root reference from XRef stream content
3313fn extract_root_from_xref_stream(content: &str) -> Option<u32> {
3314    // Look for pattern "/Root <number> 0 R" in XRef stream objects
3315    // This is more reliable than searching for catalog objects
3316
3317    // Find all XRef stream objects (containing "/Type /XRef")
3318    let lines: Vec<&str> = content.lines().collect();
3319    let mut in_xref_obj = false;
3320
3321    for (i, line) in lines.iter().enumerate() {
3322        // Check if we're starting an XRef object
3323        if line.contains(" obj")
3324            && lines
3325                .get(i + 1)
3326                .map_or(false, |next| next.contains("/Type /XRef"))
3327        {
3328            in_xref_obj = true;
3329            continue;
3330        }
3331
3332        // Check if we're in an XRef object and look for /Root
3333        if in_xref_obj {
3334            if line.contains("endobj") {
3335                in_xref_obj = false;
3336                continue;
3337            }
3338
3339            // Look for /Root pattern: "/Root 102 0 R"
3340            if let Some(root_pos) = line.find("/Root ") {
3341                let after_root = &line[root_pos + 6..]; // Skip "/Root "
3342
3343                // Extract the number before " 0 R"
3344                if let Some(space_pos) = after_root.find(' ') {
3345                    let number_part = &after_root[..space_pos];
3346                    if let Ok(root_obj) = number_part.parse::<u32>() {
3347                        tracing::debug!("Extracted Root {} from XRef stream", root_obj);
3348                        return Some(root_obj);
3349                    }
3350                }
3351            }
3352        }
3353    }
3354
3355    None
3356}
3357
3358/// Find catalog by searching content and validating structure
3359/// FIX for Issue #93: Use byte-based operations to avoid UTF-8 boundary panics
3360fn find_catalog_by_content<R: Read + Seek>(
3361    reader: &mut R,
3362    table: &XRefTable,
3363) -> ParseResult<Option<u32>> {
3364    // Deterministic order (Issue #339 / #334): iterate object numbers sorted,
3365    // not in HashMap order, so the chosen catalog is stable across runs.
3366    let mut obj_numbers: Vec<u32> = table.entries.keys().copied().collect();
3367    obj_numbers.sort_unstable();
3368
3369    for obj_num in obj_numbers {
3370        let offset = match table.entries.get(&obj_num) {
3371            Some(entry) if entry.in_use => entry.offset,
3372            _ => continue,
3373        };
3374        // Read a bounded window of the object and validate "/Type /Catalog".
3375        if let Some(content) = read_object_content(reader, obj_num, offset)? {
3376            if content.contains("/Type /Catalog") {
3377                tracing::debug!(
3378                    "Found catalog candidate at object {} (validated structure)",
3379                    obj_num
3380                );
3381                return Ok(Some(obj_num));
3382            }
3383        }
3384    }
3385
3386    tracing::debug!("No valid catalog found by content search");
3387    Ok(None)
3388}