Skip to main content

vole_document/adapter/pdf/
physical.rs

1//! Byte-authoritative physical classifier for PDF input (Phase 3.2).
2//!
3//! This pass builds on the lexical cover from [`super::lexer`] and partitions the
4//! whole input `[0, len)` into structural [`PhysicalSpan`]s. It is deliberately
5//! conservative:
6//!
7//! * Keyword recognition looks **only** at `Regular` lexemes. Because a literal
8//!   string, hex string, or comment is a single opaque span, an `endobj`,
9//!   `stream`, or `xref` spelling inside such a span can never be mistaken for
10//!   structure.
11//! * Stream data is the one region treated as raw, opaque bytes: between the EOL
12//!   following the `stream` keyword and the matching `endstream` keyword nothing
13//!   is interpreted, so `obj`/`endobj`/`stream` spellings inside payload bytes
14//!   cannot split an object.
15//! * When a construct cannot be recognised confidently it falls back to
16//!   `Unclassified` (for non-structural lexemes) or `ObjBody` (for a trailing
17//!   object with no `endobj`), never inventing or dropping bytes.
18//!
19//! The invariant is the same as the lexical layer: the emitted spans are a
20//! contiguous cover of exactly `input.len()` bytes. [`scan`] verifies this before
21//! returning, so a classifier bug becomes a classified
22//! [`crate::ErrorClass::CoverageViolation`] instead of silent loss.
23
24use crate::error::{Error, Result};
25use crate::limits::Limits;
26
27use super::cos::{
28    FilterClass, LengthValue, body_as_u64, dict_filter, dict_has_length, dict_int_or_ref,
29    dict_length, dict_name_value,
30};
31use super::lexer::lex;
32use super::span::{Span, SpanKind};
33
34/// The physical role of a byte span in a PDF file.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PhysicalKind {
37    /// The leading `%PDF-` header comment.
38    Header,
39    /// A `%` comment line (including binary/UTF-8 markers).
40    Comment,
41    /// A run of PDF whitespace bytes.
42    Whitespace,
43    /// The `N G obj` introducer of an indirect object.
44    ObjHeader,
45    /// Indirect-object body bytes that are not stream data.
46    ObjBody,
47    /// The `endobj` keyword.
48    EndObj,
49    /// Raw bytes of a stream payload (between EOL and `endstream`).
50    StreamData,
51    /// A classic `xref`-to-`trailer` cross-reference section.
52    XrefSection,
53    /// A `trailer` keyword plus its dictionary, when present.
54    Trailer,
55    /// A `startxref` keyword plus its offset value.
56    StartXref,
57    /// A `%%EOF` marker.
58    Eof,
59    /// A lexeme that could not be confidently classified (residual authority).
60    Unclassified,
61}
62
63/// One physical span: a half-open byte range `[start, start + len)`.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct PhysicalSpan {
66    /// Offset of the first byte of the span.
67    pub start: u64,
68    /// Number of bytes in the span (always > 0 in a valid cover).
69    pub len: u64,
70    /// The physical role of the span.
71    pub kind: PhysicalKind,
72}
73
74/// The structural role of an indirect object, inferred from its leading
75/// dictionary.
76///
77/// The classification is deliberately conservative: only a leading `<<...>>`
78/// dictionary whose `/Type` is a simple name is inspected, so anything ambiguous
79/// remains [`ObjRole::Generic`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ObjRole {
82    /// An ordinary object, or one whose role could not be established.
83    Generic,
84    /// An object whose leading dictionary is `/Type /XRef` (a cross-reference
85    /// stream).
86    XRefStream,
87    /// An object whose leading dictionary is `/Type /ObjStm` (an object stream).
88    ObjectStream,
89}
90
91/// An indirect object discovered in file order.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct PdfObjectSpan {
94    /// The object number `N`.
95    pub number: u64,
96    /// The generation number `G`.
97    pub generation: u64,
98    /// Offset of the `N` introducer.
99    pub start: u64,
100    /// Offset just past the closing `endobj`.
101    pub end: u64,
102    /// Structural role inferred from the leading dictionary.
103    pub role: ObjRole,
104}
105
106/// How a stream's data length was determined.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum LengthSource {
109    /// A direct `/Length N` was read and verified against `endstream`.
110    Direct,
111    /// An indirect `/Length N G R` was resolved and verified.
112    Indirect,
113    /// No usable `/Length`; the conservative 3.2 keyword search was used.
114    Fallback,
115    /// No `/Length` key was present at all; the keyword search was used.
116    Missing,
117}
118
119/// The exact data span of one stream, with the provenance of its length.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct PdfStreamSpan {
122    /// Object number of the enclosing indirect object.
123    pub object: u64,
124    /// Generation number of the enclosing indirect object.
125    pub generation: u64,
126    /// Offset of the first payload byte (after the post-`stream` EOL).
127    pub data_start: u64,
128    /// Number of payload bytes.
129    pub data_len: u64,
130    /// How `data_len` was established.
131    pub length_source: LengthSource,
132    /// Classification of the stream dictionary's `/Filter` entry.
133    pub filter: FilterClass,
134}
135
136/// One incremental-update revision, delimited by a terminating `%%EOF`.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct RevisionInfo {
139    /// Zero-based position of the revision in file order.
140    pub index: u32,
141    /// First byte of the revision.
142    pub start: u64,
143    /// Byte just past the terminating `%%EOF` comment.
144    pub end: u64,
145    /// The recorded `startxref` value whose keyword lies in this revision.
146    pub startxref: Option<u64>,
147    /// The resolved `/Prev` offset of this revision's cross-reference anchor, if
148    /// any.
149    pub prev: Option<u64>,
150}
151
152/// The physical summary of a PDF input.
153#[derive(Debug, Clone, PartialEq, Eq, Default)]
154pub struct PdfPhysical {
155    /// A contiguous cover of the input, in ascending offset order.
156    pub spans: Vec<PhysicalSpan>,
157    /// Indirect objects found, in file order.
158    pub objects: Vec<PdfObjectSpan>,
159    /// Resolved stream payload spans, in file order.
160    pub streams: Vec<PdfStreamSpan>,
161    /// Revision records delimited by `%%EOF`, in file order.
162    pub revisions: Vec<RevisionInfo>,
163    /// Recorded `startxref` values, in file order.
164    pub startxref: Vec<u64>,
165    /// Offsets of `%%EOF` markers, in file order.
166    pub eofs: Vec<u64>,
167    /// `(start, len)` of the `%PDF-` header comment, if present.
168    pub header: Option<(u64, u64)>,
169}
170
171impl PdfPhysical {
172    /// Sum of all span lengths. Saturates rather than panicking.
173    pub fn total_len(&self) -> u64 {
174        self.spans
175            .iter()
176            .fold(0u64, |acc, s| acc.saturating_add(s.len))
177    }
178
179    /// Require a contiguous cover of exactly `[0, declared_len)`.
180    ///
181    /// Returns [`crate::ErrorClass::CoverageViolation`] for a gap, overlap,
182    /// wrong total, or length overflow, and
183    /// [`crate::ErrorClass::InvalidPdfStructure`] for a zero-length span.
184    pub fn validate(&self, declared_len: u64) -> Result<()> {
185        let mut cursor: u64 = 0;
186        for (i, span) in self.spans.iter().enumerate() {
187            if span.len == 0 {
188                return Err(Error::invalid_pdf_structure(format!(
189                    "physical span {i} has zero length at offset {}",
190                    span.start
191                )));
192            }
193            if span.start != cursor {
194                let why = if span.start < cursor {
195                    "overlap"
196                } else {
197                    "gap"
198                };
199                return Err(Error::coverage_violation(format!(
200                    "physical span {i} {why}: expected start {cursor}, found {}",
201                    span.start
202                )));
203            }
204            cursor = cursor.checked_add(span.len).ok_or_else(|| {
205                Error::coverage_violation("physical span lengths overflow the address space")
206            })?;
207        }
208        if cursor != declared_len {
209            return Err(Error::coverage_violation(format!(
210                "physical cover ends at {cursor}, declared length is {declared_len}"
211            )));
212        }
213        Ok(())
214    }
215}
216
217/// Build a conservative physical classification of `input` under `limits`.
218pub fn scan(input: &[u8], limits: Limits) -> Result<PdfPhysical> {
219    let declared_len = input.len() as u64;
220    let lexed = lex(input, limits)?;
221    let spans = lexed.spans.spans;
222    let dm = DictMatch::build(&spans);
223
224    // A prior pass resolves object body ranges, so an indirect `/Length` can be
225    // resolved even when the target object appears later in the file.
226    let obj_bodies = collect_bodies(input, &spans);
227    let body_index = build_body_index(&obj_bodies);
228    let mut state = ScanState::new(limits, body_index);
229    let t_loop = std::time::Instant::now();
230    let mut niter: u64 = 0;
231    let mut i = 0usize;
232    while i < spans.len() {
233        niter += 1;
234        let sp = spans[i];
235        let bytes = bytes_of(input, sp);
236
237        // 2. Header: the comment at offset 0 beginning `%PDF-`.
238        if state.header.is_none()
239            && sp.start == 0
240            && sp.kind == SpanKind::Comment
241            && bytes.starts_with(b"%PDF-")
242        {
243            state.push(sp.start, sp.len, PhysicalKind::Header)?;
244            state.header = Some((sp.start, sp.len));
245            i += 1;
246            continue;
247        }
248
249        // 6. End-of-file marker.
250        if sp.kind == SpanKind::Comment && bytes.starts_with(b"%%EOF") {
251            state.push(sp.start, sp.len, PhysicalKind::Eof)?;
252            state.eofs.push(sp.start);
253            i += 1;
254            continue;
255        }
256
257        // 3-5. Structural keywords are recognised only on Regular lexemes.
258        if sp.kind == SpanKind::Regular {
259            if bytes == b"startxref" {
260                if let Some(next) = state.try_startxref(input, &spans, i)? {
261                    i = next;
262                    continue;
263                }
264            } else if bytes == b"xref" {
265                i = state.emit_xref(input, &spans, i, &dm)?;
266                continue;
267            } else if bytes == b"trailer" {
268                i = state.emit_trailer(&spans, i, &dm)?;
269                continue;
270            } else if let Some((number, generation)) = obj_header_at(input, &spans, i) {
271                i = state.emit_object(input, &spans, i, number, generation, &dm)?;
272                continue;
273            }
274        }
275
276        // 7. Residual lexemes retain their lexical class or fall to Unclassified.
277        let kind = match sp.kind {
278            SpanKind::Comment => PhysicalKind::Comment,
279            SpanKind::Whitespace => PhysicalKind::Whitespace,
280            _ => PhysicalKind::Unclassified,
281        };
282        state.push(sp.start, sp.len, kind)?;
283        i += 1;
284    }
285
286    if std::env::var_os("VOLE_PROPOSE_TRACE").is_some() {
287        eprintln!(
288            "[scan] loop={}ms iters={} nspans={}",
289            t_loop.elapsed().as_millis(),
290            niter,
291            spans.len()
292        );
293    }
294    let physical = state.finish(input, &spans, &dm);
295    physical.validate(declared_len)?;
296    Ok(physical)
297}
298
299/// Mutable accumulator for [`scan`].
300struct ScanState {
301    builder: Builder,
302    objects: Vec<PdfObjectSpan>,
303    streams: Vec<PdfStreamSpan>,
304    body_index: BodyIndex,
305    startxref: Vec<(u64, u64)>,
306    eofs: Vec<u64>,
307    trailer_dicts: Vec<(u64, u64)>,
308    header: Option<(u64, u64)>,
309}
310
311impl ScanState {
312    fn new(limits: Limits, body_index: BodyIndex) -> Self {
313        ScanState {
314            builder: Builder::new(limits),
315            objects: Vec::new(),
316            streams: Vec::new(),
317            body_index,
318            startxref: Vec::new(),
319            eofs: Vec::new(),
320            trailer_dicts: Vec::new(),
321            header: None,
322        }
323    }
324
325    fn push(&mut self, start: u64, len: u64, kind: PhysicalKind) -> Result<()> {
326        self.builder.push(start, len, kind)
327    }
328
329    fn finish(self, input: &[u8], spans: &[Span], dm: &DictMatch) -> PdfPhysical {
330        let revisions = build_revisions(
331            input,
332            spans,
333            &self.eofs,
334            &self.objects,
335            &self.startxref,
336            &self.trailer_dicts,
337            dm,
338        );
339        PdfPhysical {
340            spans: self.builder.spans,
341            objects: self.objects,
342            streams: self.streams,
343            revisions,
344            startxref: self.startxref.iter().map(|&(_, value)| value).collect(),
345            eofs: self.eofs,
346            header: self.header,
347        }
348    }
349
350    /// Emit the classification of the indirect object whose introducer starts at
351    /// lexeme `i`; returns the index of the first lexeme after the object.
352    fn emit_object(
353        &mut self,
354        input: &[u8],
355        spans: &[Span],
356        i: usize,
357        number: u64,
358        generation: u64,
359        dm: &DictMatch,
360    ) -> Result<usize> {
361        let obj_header_start = spans[i].start;
362        let obj_kw_end = spans[i + 4].start + spans[i + 4].len;
363        let role = leading_dict_role(dm, input, spans, i + 5);
364        self.push(
365            obj_header_start,
366            obj_kw_end - obj_header_start,
367            PhysicalKind::ObjHeader,
368        )?;
369
370        // Locate the object end: the first `endobj` after the introducer, with an
371        // optional stream payload skipped wholesale. Streams use a resolved
372        // `/Length` when possible and the conservative 3.2 keyword search otherwise.
373        let mut stream: Option<ResolvedStream> = None;
374        let mut endobj: Option<usize> = None;
375        let mut j = i + 5;
376        while j < spans.len() {
377            if regular_eq(input, spans[j], b"endobj") {
378                endobj = Some(j);
379                break;
380            }
381            if stream.is_none()
382                && regular_eq(input, spans[j], b"stream")
383                && let Some(rs) = resolve_stream(input, spans, j, &self.body_index, i + 5, dm)
384            {
385                j = rs.endstream + 1;
386                stream = Some(rs);
387                continue;
388            }
389            j += 1;
390        }
391
392        match endobj {
393            Some(m) => {
394                if let Some(rs) = stream {
395                    if rs.data_start > obj_kw_end {
396                        self.push(
397                            obj_kw_end,
398                            rs.data_start - obj_kw_end,
399                            PhysicalKind::ObjBody,
400                        )?;
401                    }
402                    if rs.data_len > 0 {
403                        self.push(rs.data_start, rs.data_len, PhysicalKind::StreamData)?;
404                    }
405                    // Resume at the end of the declared payload so any optional
406                    // trailing EOL is still covered (it is not part of the span).
407                    let data_end = rs.data_start + rs.data_len;
408                    let body_end = spans[m].start;
409                    if body_end > data_end {
410                        self.push(data_end, body_end - data_end, PhysicalKind::ObjBody)?;
411                    }
412                    self.streams.push(PdfStreamSpan {
413                        object: number,
414                        generation,
415                        data_start: rs.data_start,
416                        data_len: rs.data_len,
417                        length_source: rs.source,
418                        filter: rs.filter,
419                    });
420                } else {
421                    let body_end = spans[m].start;
422                    if body_end > obj_kw_end {
423                        self.push(obj_kw_end, body_end - obj_kw_end, PhysicalKind::ObjBody)?;
424                    }
425                }
426
427                self.push(spans[m].start, spans[m].len, PhysicalKind::EndObj)?;
428                self.objects.push(PdfObjectSpan {
429                    number,
430                    generation,
431                    start: obj_header_start,
432                    end: spans[m].start + spans[m].len,
433                    role,
434                });
435                Ok(m + 1)
436            }
437            None => {
438                // Malformed: no `endobj`. Keep the remainder as body bytes.
439                let end = input.len() as u64;
440                if end > obj_kw_end {
441                    self.push(obj_kw_end, end - obj_kw_end, PhysicalKind::ObjBody)?;
442                }
443                Ok(spans.len())
444            }
445        }
446    }
447
448    /// Emit a classic `xref` section, stopping at the following `trailer`,
449    /// `startxref`, or `%%EOF`. Returns the next lexeme index.
450    fn emit_xref(
451        &mut self,
452        input: &[u8],
453        spans: &[Span],
454        i: usize,
455        dm: &DictMatch,
456    ) -> Result<usize> {
457        let start = spans[i].start;
458        let mut end_idx = spans.len();
459        for (k, sp) in spans.iter().enumerate().skip(i + 1) {
460            let sp = *sp;
461            if regular_eq(input, sp, b"trailer") || regular_eq(input, sp, b"startxref") {
462                end_idx = k;
463                break;
464            }
465            if sp.kind == SpanKind::Comment && bytes_of(input, sp).starts_with(b"%%EOF") {
466                end_idx = k;
467                break;
468            }
469        }
470
471        let end = if end_idx < spans.len() {
472            spans[end_idx].start
473        } else {
474            input.len() as u64
475        };
476        if end > start {
477            self.push(start, end - start, PhysicalKind::XrefSection)?;
478        }
479
480        if end_idx < spans.len() && regular_eq(input, spans[end_idx], b"trailer") {
481            self.emit_trailer(spans, end_idx, dm)
482        } else {
483            Ok(end_idx)
484        }
485    }
486
487    /// Emit a `trailer` keyword plus its first dictionary, if any. Returns the
488    /// next lexeme index.
489    fn emit_trailer(&mut self, spans: &[Span], t: usize, dm: &DictMatch) -> Result<usize> {
490        let start = spans[t].start;
491        let mut end = spans[t].start + spans[t].len;
492        let mut next = t + 1;
493        let dict_idx = if next < spans.len() && spans[next].kind == SpanKind::Whitespace {
494            next + 1
495        } else {
496            next
497        };
498        if dict_idx < spans.len()
499            && spans[dict_idx].kind == SpanKind::DictOpen
500            && let Some(close) = dm.matching(dict_idx)
501        {
502            let lo = spans[dict_idx].start;
503            let hi = spans[close].start + spans[close].len;
504            self.trailer_dicts.push((lo, hi));
505            end = hi;
506            next = close + 1;
507        }
508        if end > start {
509            self.push(start, end - start, PhysicalKind::Trailer)?;
510        }
511        Ok(next)
512    }
513
514    /// Emit a `startxref` keyword plus its value if the pattern matches.
515    fn try_startxref(&mut self, input: &[u8], spans: &[Span], i: usize) -> Result<Option<usize>> {
516        if i + 2 < spans.len()
517            && spans[i + 1].kind == SpanKind::Whitespace
518            && spans[i + 2].kind == SpanKind::Regular
519            && let Some(value) = parse_uint(bytes_of(input, spans[i + 2]), 19)
520        {
521            let start = spans[i].start;
522            let end = spans[i + 2].start + spans[i + 2].len;
523            self.push(start, end - start, PhysicalKind::StartXref)?;
524            self.startxref.push((start, value));
525            return Ok(Some(i + 3));
526        }
527        Ok(None)
528    }
529}
530
531/// Append-only physical span builder that merges adjacent equal kinds and
532/// enforces the span-count bound.
533struct Builder {
534    max: u32,
535    spans: Vec<PhysicalSpan>,
536}
537
538impl Builder {
539    fn new(limits: Limits) -> Self {
540        Builder {
541            max: limits.max_pdf_spans,
542            spans: Vec::new(),
543        }
544    }
545
546    fn push(&mut self, start: u64, len: u64, kind: PhysicalKind) -> Result<()> {
547        if len == 0 {
548            return Ok(());
549        }
550        if let Some(last) = self.spans.last_mut()
551            && last.kind == kind
552            && last.start.checked_add(last.len) == Some(start)
553        {
554            last.len = last
555                .len
556                .checked_add(len)
557                .ok_or_else(|| Error::coverage_violation("physical span length overflow"))?;
558            return Ok(());
559        }
560        if self.spans.len() as u64 >= self.max as u64 {
561            return Err(Error::resource_limit(format!(
562                "pdf physical span count exceeds limit {}",
563                self.max
564            )));
565        }
566        self.spans.push(PhysicalSpan { start, len, kind });
567        Ok(())
568    }
569}
570
571/// The byte slice backing `sp`, or empty if the offset is out of range.
572fn bytes_of(input: &[u8], sp: Span) -> &[u8] {
573    let Ok(start) = usize::try_from(sp.start) else {
574        return &[];
575    };
576    let Some(end) = sp
577        .start
578        .checked_add(sp.len)
579        .and_then(|e| usize::try_from(e).ok())
580    else {
581        return &[];
582    };
583    if start > end || end > input.len() {
584        return &[];
585    }
586    &input[start..end]
587}
588
589/// Whether `sp` is a `Regular` lexeme exactly equal to `keyword`.
590fn regular_eq(input: &[u8], sp: Span, keyword: &[u8]) -> bool {
591    sp.kind == SpanKind::Regular && bytes_of(input, sp) == keyword
592}
593
594/// Parse an unsigned ASCII integer of at most `max_digits` digits.
595fn parse_uint(bytes: &[u8], max_digits: usize) -> Option<u64> {
596    if bytes.is_empty() || bytes.len() > max_digits {
597        return None;
598    }
599    let mut value: u64 = 0;
600    for &b in bytes {
601        if !b.is_ascii_digit() {
602            return None;
603        }
604        value = value.checked_mul(10)?.checked_add(u64::from(b - b'0'))?;
605    }
606    Some(value)
607}
608
609/// Offset of the first CR or LF at or after `offset`.
610fn eol_start_after(input: &[u8], offset: u64) -> Option<u64> {
611    let start = usize::try_from(offset).ok()?;
612    if start > input.len() {
613        return None;
614    }
615    (start..input.len())
616        .find(|&i| input[i] == b'\n' || input[i] == b'\r')
617        .map(|i| i as u64)
618}
619
620/// Index of the first `Regular` lexeme equal to `keyword` at or after `from`.
621fn find_regular(input: &[u8], spans: &[Span], from: usize, keyword: &[u8]) -> Option<usize> {
622    if from >= spans.len() {
623        return None;
624    }
625    spans[from..]
626        .iter()
627        .position(|sp| regular_eq(input, *sp, keyword))
628        .map(|off| from + off)
629}
630
631/// Index of the `>>` matching the `<<` at `open`, honouring nesting.
632/// Precomputed `<<`→`>>` matching over the lexed spans.
633///
634/// The physical scan asks for a dictionary's matching close once per indirect
635/// object (and per `trailer`). Computing it by a forward scan from each `<<` is
636/// `O(spans)` per call; image/binary payloads contain many stray `<<` with no
637/// nearby `>>`, which makes the whole scan quadratic on large scanned PDFs. One
638/// stack pass builds the full matching in `O(spans)`, so each query is `O(1)`
639/// and the result is identical (first balanced close, `None` if unbalanced).
640struct DictMatch {
641    close: Vec<u32>,
642}
643
644impl DictMatch {
645    fn build(spans: &[Span]) -> Self {
646        let mut close = vec![u32::MAX; spans.len()];
647        let mut stack: Vec<u32> = Vec::new();
648        for (k, sp) in spans.iter().enumerate() {
649            match sp.kind {
650                SpanKind::DictOpen => stack.push(k as u32),
651                SpanKind::DictClose => {
652                    if let Some(o) = stack.pop() {
653                        close[o as usize] = k as u32;
654                    }
655                }
656                _ => {}
657            }
658        }
659        DictMatch { close }
660    }
661
662    /// The first balanced `DictClose` for the `DictOpen` at `open`, or `None`.
663    fn matching(&self, open: usize) -> Option<usize> {
664        match self.close.get(open).copied() {
665            Some(c) if c != u32::MAX => Some(c as usize),
666            _ => None,
667        }
668    }
669}
670
671/// Recognise `N G obj` starting at lexeme `i`, returning `(N, G)`.
672fn obj_header_at(input: &[u8], spans: &[Span], i: usize) -> Option<(u64, u64)> {
673    if i + 4 >= spans.len() {
674        return None;
675    }
676    if spans[i].kind != SpanKind::Regular {
677        return None;
678    }
679    let number = parse_uint(bytes_of(input, spans[i]), 10)?;
680    if spans[i + 1].kind != SpanKind::Whitespace {
681        return None;
682    }
683    if spans[i + 2].kind != SpanKind::Regular {
684        return None;
685    }
686    let generation = parse_uint(bytes_of(input, spans[i + 2]), 10)?;
687    if spans[i + 3].kind != SpanKind::Whitespace {
688        return None;
689    }
690    if !regular_eq(input, spans[i + 4], b"obj") {
691        return None;
692    }
693    Some((number, generation))
694}
695
696// ---------------------------------------------------------------------------
697// Stream length resolution.
698// ---------------------------------------------------------------------------
699
700/// A pre-resolved indirect-object body range, keyed by `(number, generation)`.
701#[derive(Debug, Clone, Copy)]
702struct ObjBody {
703    number: u64,
704    generation: u64,
705    body_lo: u64,
706    body_hi: u64,
707}
708
709/// A resolved stream payload: exact data span plus the length's provenance.
710#[derive(Debug, Clone, Copy)]
711struct ResolvedStream {
712    data_start: u64,
713    data_len: u64,
714    endstream: usize,
715    source: LengthSource,
716    filter: FilterClass,
717}
718
719/// One prior pass over the lexical cover records every object's body range using
720/// the same conservative stream skip as the main pass. This lets the main pass
721/// resolve an indirect `/Length` even when the target object appears later.
722fn collect_bodies(input: &[u8], spans: &[Span]) -> Vec<ObjBody> {
723    let mut out = Vec::new();
724    let mut i = 0usize;
725    while i < spans.len() {
726        if let Some((number, generation)) = obj_header_at(input, spans, i) {
727            let body_lo = spans[i + 4].start + spans[i + 4].len;
728            match find_endobj(input, spans, i + 5) {
729                Some(m) => {
730                    out.push(ObjBody {
731                        number,
732                        generation,
733                        body_lo,
734                        body_hi: spans[m].start,
735                    });
736                    i = m + 1;
737                }
738                None => {
739                    out.push(ObjBody {
740                        number,
741                        generation,
742                        body_lo,
743                        body_hi: input.len() as u64,
744                    });
745                    i = spans.len();
746                }
747            }
748        } else {
749            i += 1;
750        }
751    }
752    out
753}
754
755/// Index of the terminating `endobj`, skipping a stream payload wholesale with
756/// the conservative keyword rule used by the 3.2 scanner.
757fn find_endobj(input: &[u8], spans: &[Span], from: usize) -> Option<usize> {
758    let mut j = from;
759    while j < spans.len() {
760        if regular_eq(input, spans[j], b"endobj") {
761            return Some(j);
762        }
763        if regular_eq(input, spans[j], b"stream") {
764            let kw_end = spans[j].start + spans[j].len;
765            if let Some(data_start) = eol_start_after(input, kw_end)
766                && let Some(k) = find_regular(input, spans, j + 1, b"endstream")
767                && spans[k].start >= data_start
768            {
769                j = k + 1;
770                continue;
771            }
772        }
773        j += 1;
774    }
775    None
776}
777
778/// Body range of object `(number, generation)`, if present.
779/// `(number, generation) -> (body_lo, body_hi)`, keeping the **first** body for a
780/// repeated key (matching the linear `lookup_body` it replaces). An indirect
781/// `/Length` is resolved through this index once per stream; the linear scan was
782/// `O(streams x bodies)`, which made the physical scan quadratic on scanned PDFs
783/// whose image bytes spawn thousands of spurious objects.
784type BodyIndex = std::collections::HashMap<(u64, u64), (u64, u64)>;
785
786fn build_body_index(bodies: &[ObjBody]) -> BodyIndex {
787    let mut index = BodyIndex::with_capacity(bodies.len());
788    for b in bodies {
789        index
790            .entry((b.number, b.generation))
791            .or_insert((b.body_lo, b.body_hi));
792    }
793    index
794}
795
796/// Resolve a `stream` keyword at lexeme `s` to its exact payload span.
797///
798/// Precedence: direct `/Length`, then indirect `/Length` (resolved through the
799/// object-body index), then the conservative 3.2 keyword search. Returns `None`
800/// only when not even the keyword search finds a terminating `endstream`.
801fn resolve_stream(
802    input: &[u8],
803    spans: &[Span],
804    s: usize,
805    body_index: &BodyIndex,
806    lower: usize,
807    dm: &DictMatch,
808) -> Option<ResolvedStream> {
809    let kw_end = spans[s].start + spans[s].len;
810
811    let mut resolved: Option<ResolvedStream> = None;
812    let mut source = LengthSource::Fallback;
813    let mut filter = FilterClass::Absent;
814
815    if let Some((open, close)) = preceding_dict(spans, s, lower, dm) {
816        let dict_lo = spans[open].start;
817        let dict_hi = spans[close].start + spans[close].len;
818        filter = dict_filter(input, spans, dict_lo, dict_hi);
819        match dict_length(input, spans, dict_lo, dict_hi) {
820            Some(LengthValue::Direct(n)) => {
821                if let Some(rs) = verify_direct(input, spans, kw_end, n, filter) {
822                    resolved = Some(rs);
823                    source = LengthSource::Direct;
824                }
825            }
826            Some(LengthValue::Indirect { number, generation }) => {
827                if let Some(&(lo, hi)) = body_index.get(&(number, generation))
828                    && let Some(n) = body_as_u64(input, spans, lo, hi)
829                    && let Some(rs) = verify_direct(input, spans, kw_end, n, filter)
830                {
831                    resolved = Some(rs);
832                    source = LengthSource::Indirect;
833                }
834            }
835            None => {
836                if !dict_has_length(input, spans, dict_lo, dict_hi) {
837                    source = LengthSource::Missing;
838                }
839            }
840        }
841    } else {
842        source = LengthSource::Missing;
843    }
844
845    if let Some(mut rs) = resolved {
846        rs.source = source;
847        return Some(rs);
848    }
849
850    // Conservative 3.2 fallback: the payload runs from the first EOL after the
851    // `stream` keyword to the first following `endstream` keyword.
852    let data_start = eol_start_after(input, kw_end)?;
853    let k = find_regular(input, spans, s + 1, b"endstream")?;
854    if spans[k].start < data_start {
855        return None;
856    }
857    Some(ResolvedStream {
858        data_start,
859        data_len: spans[k].start - data_start,
860        endstream: k,
861        source,
862        filter,
863    })
864}
865
866/// The `<<...>>` dictionary immediately preceding `stream` at `s`, as
867/// `(open, close)` lexeme indices: the nearest `DictOpen` whose matching
868/// `DictClose` lies before `s`.
869fn preceding_dict(
870    spans: &[Span],
871    s: usize,
872    lower: usize,
873    dm: &DictMatch,
874) -> Option<(usize, usize)> {
875    let mut j = s;
876    while j > lower {
877        j -= 1;
878        if spans[j].kind == SpanKind::DictOpen
879            && let Some(close) = dm.matching(j)
880            && close < s
881        {
882            return Some((j, close));
883        }
884    }
885    None
886}
887
888/// Verify a direct length `n` against the bytes after `stream`: exactly one EOL
889/// must follow the keyword, and after `n` bytes an optional EOL must reach a
890/// `Regular` `endstream`. A lone CR after `stream` is not a valid EOL.
891fn verify_direct(
892    input: &[u8],
893    spans: &[Span],
894    kw_end: u64,
895    n: u64,
896    filter: FilterClass,
897) -> Option<ResolvedStream> {
898    let eol = post_stream_eol_len(input, kw_end)?;
899    let data_start = kw_end + eol;
900    let data_end = data_start.checked_add(n)?;
901    let k = first_regular_at_or_after(input, spans, data_end, b"endstream")?;
902    let gap = spans[k].start.checked_sub(data_end)?;
903    let ok = gap == 0
904        || (gap == 1 && byte_at(input, data_end) == Some(b'\n'))
905        || (gap == 2
906            && byte_at(input, data_end) == Some(b'\r')
907            && byte_at(input, data_end + 1) == Some(b'\n'));
908    if !ok {
909        return None;
910    }
911    Some(ResolvedStream {
912        data_start,
913        data_len: n,
914        endstream: k,
915        source: LengthSource::Direct,
916        filter,
917    })
918}
919
920/// Length of the mandatory EOL directly after the `stream` keyword: `LF` (1),
921/// `CRLF` (2), or `None` (including a lone `CR`).
922fn post_stream_eol_len(input: &[u8], kw_end: u64) -> Option<u64> {
923    match byte_at(input, kw_end) {
924        Some(b'\n') => Some(1),
925        Some(b'\r') if byte_at(input, kw_end + 1) == Some(b'\n') => Some(2),
926        _ => None,
927    }
928}
929
930/// Index of the first `Regular` lexeme equal to `keyword` whose start is at or
931/// after `offset`.
932fn first_regular_at_or_after(
933    input: &[u8],
934    spans: &[Span],
935    offset: u64,
936    keyword: &[u8],
937) -> Option<usize> {
938    let idx = spans.partition_point(|sp| sp.start < offset);
939    spans[idx..]
940        .iter()
941        .position(|sp| regular_eq(input, *sp, keyword))
942        .map(|off| idx + off)
943}
944
945/// One byte at `offset`, if in range.
946fn byte_at(input: &[u8], offset: u64) -> Option<u8> {
947    usize::try_from(offset)
948        .ok()
949        .and_then(|i| input.get(i).copied())
950}
951
952/// Build revision records from the `%%EOF` offsets. `end` is the byte just past
953/// the comment; `start` is `0` for the first revision, otherwise the previous
954/// revision's end advanced by at most one optional EOL.
955fn build_revisions(
956    input: &[u8],
957    spans: &[Span],
958    eofs: &[u64],
959    objects: &[PdfObjectSpan],
960    startxref: &[(u64, u64)],
961    trailers: &[(u64, u64)],
962    dm: &DictMatch,
963) -> Vec<RevisionInfo> {
964    let mut out = Vec::with_capacity(eofs.len());
965    let mut start = 0u64;
966    for (index, &e) in eofs.iter().enumerate() {
967        let end = span_end_at(spans, e).unwrap_or(e);
968        let startxref = startxref
969            .iter()
970            .find(|&&(keyword, _)| keyword >= start && keyword < end)
971            .map(|&(_, value)| value);
972        let prev = resolve_prev(input, spans, start, end, objects, trailers, dm);
973        out.push(RevisionInfo {
974            index: index as u32,
975            start,
976            end,
977            startxref,
978            prev,
979        });
980        start = end + eol_len_after(input, end);
981    }
982    out
983}
984
985/// Resolve a revision's `/Prev` offset from its cross-reference anchor: the
986/// classic `trailer` dict if present in the revision, otherwise an `XRefStream`
987/// object's leading dict. A direct integer is used as-is; a reference is mapped
988/// to the referenced object's offset when that object exists. Returns `None` when
989/// there is no anchor, no `/Prev`, or an unresolvable reference.
990fn resolve_prev(
991    input: &[u8],
992    spans: &[Span],
993    rev_start: u64,
994    rev_end: u64,
995    objects: &[PdfObjectSpan],
996    trailers: &[(u64, u64)],
997    dm: &DictMatch,
998) -> Option<u64> {
999    let trailer = trailers
1000        .iter()
1001        .rev()
1002        .find(|&&(lo, _)| lo >= rev_start && lo < rev_end);
1003    let (dict_lo, dict_hi) = match trailer {
1004        Some(&(lo, hi)) => (lo, hi),
1005        None => {
1006            let object = objects.iter().find(|o| {
1007                o.role == ObjRole::XRefStream && o.start >= rev_start && o.start < rev_end
1008            })?;
1009            leading_dict_range_at(dm, input, spans, object.start)?
1010        }
1011    };
1012    match dict_int_or_ref(input, spans, dict_lo, dict_hi, b"Prev") {
1013        Some(LengthValue::Direct(n)) => Some(n),
1014        Some(LengthValue::Indirect { number, generation }) => objects
1015            .iter()
1016            .find(|o| o.number == number && o.generation == generation)
1017            .map(|o| o.start),
1018        None => None,
1019    }
1020}
1021
1022/// Range `[lo, hi)` of the `<<...>>` dictionary immediately following the `obj`
1023/// keyword (skipping whitespace/comments), or `None` if the next significant
1024/// token is not a dict opener.
1025fn leading_dict_range(spans: &[Span], after: usize, dm: &DictMatch) -> Option<(u64, u64)> {
1026    let mut j = after;
1027    while j < spans.len() && matches!(spans[j].kind, SpanKind::Whitespace | SpanKind::Comment) {
1028        j += 1;
1029    }
1030    if j >= spans.len() || spans[j].kind != SpanKind::DictOpen {
1031        return None;
1032    }
1033    let close = dm.matching(j)?;
1034    Some((spans[j].start, spans[close].start + spans[close].len))
1035}
1036
1037/// Classify an object by its leading dictionary's `/Type`. Conservative: only a
1038/// simple name value yields a non-generic role.
1039fn leading_dict_role(dm: &DictMatch, input: &[u8], spans: &[Span], after: usize) -> ObjRole {
1040    let Some((lo, hi)) = leading_dict_range(spans, after, dm) else {
1041        return ObjRole::Generic;
1042    };
1043    match dict_name_value(input, spans, lo, hi, b"Type") {
1044        Some(name) if name == b"XRef".as_slice() => ObjRole::XRefStream,
1045        Some(name) if name == b"ObjStm".as_slice() => ObjRole::ObjectStream,
1046        _ => ObjRole::Generic,
1047    }
1048}
1049
1050/// Range `[lo, hi)` of the leading dictionary of the object whose introducer
1051/// starts at `start`, or `None` if no object introducer begins there.
1052fn leading_dict_range_at(
1053    dm: &DictMatch,
1054    input: &[u8],
1055    spans: &[Span],
1056    start: u64,
1057) -> Option<(u64, u64)> {
1058    let idx = spans.partition_point(|sp| sp.start < start);
1059    if idx >= spans.len() || spans[idx].start != start {
1060        return None;
1061    }
1062    obj_header_at(input, spans, idx)?;
1063    leading_dict_range(spans, idx + 5, dm)
1064}
1065
1066/// End offset of the span containing `offset`, if the offset lies within one.
1067fn span_end_at(spans: &[Span], offset: u64) -> Option<u64> {
1068    let idx = spans.partition_point(|sp| sp.start <= offset);
1069    if idx == 0 {
1070        return None;
1071    }
1072    let sp = spans[idx - 1];
1073    if offset < sp.start.saturating_add(sp.len) {
1074        Some(sp.start.saturating_add(sp.len))
1075    } else {
1076        None
1077    }
1078}
1079
1080/// Length of one optional EOL at `offset`: `LF` (1), `CRLF` (2), lone `CR` (1),
1081/// or `0` when none is present.
1082fn eol_len_after(input: &[u8], offset: u64) -> u64 {
1083    match byte_at(input, offset) {
1084        Some(b'\n') => 1,
1085        Some(b'\r') if byte_at(input, offset + 1) == Some(b'\n') => 2,
1086        Some(b'\r') => 1,
1087        _ => 0,
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094    use crate::error::ErrorClass;
1095
1096    fn count(p: &PdfPhysical, kind: PhysicalKind) -> usize {
1097        p.spans.iter().filter(|s| s.kind == kind).count()
1098    }
1099
1100    fn canonical_pdf() -> Vec<u8> {
1101        let mut s = String::new();
1102        s.push_str("%PDF-1.7\n");
1103        s.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1104        s.push_str("2 0 obj\n<< /Length 6 >>\nstream\nhello\nendstream\nendobj\n");
1105        s.push_str("3 0 obj\n<< /Length 7 >>\nstream\nworld\nendstream\nendobj\n");
1106        s.push_str("4 0 obj\n<< /Length 4 >>\nstream\nxyz\nendstream\nendobj\n");
1107        s.push_str("xref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n");
1108        s.push_str("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n321\n%%EOF");
1109        s.into_bytes()
1110    }
1111
1112    #[test]
1113    fn canonical_pdf_is_fully_classified() {
1114        let pdf = canonical_pdf();
1115        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1116
1117        assert_eq!(p.header, Some((0, 8)));
1118        assert_eq!(p.objects.len(), 4);
1119        let nums: Vec<(u64, u64)> = p.objects.iter().map(|o| (o.number, o.generation)).collect();
1120        assert_eq!(nums, [(1, 0), (2, 0), (3, 0), (4, 0)]);
1121        assert_eq!(p.startxref, [321]);
1122        assert_eq!(p.eofs.len(), 1);
1123        assert_eq!(count(&p, PhysicalKind::EndObj), 4);
1124        assert_eq!(count(&p, PhysicalKind::StreamData), 3);
1125        assert_eq!(count(&p, PhysicalKind::XrefSection), 1);
1126        assert_eq!(count(&p, PhysicalKind::Trailer), 1);
1127        assert_eq!(count(&p, PhysicalKind::ObjHeader), 4);
1128        assert_eq!(p.total_len(), pdf.len() as u64);
1129        p.validate(pdf.len() as u64).unwrap();
1130    }
1131
1132    #[test]
1133    fn endobj_inside_literal_string_does_not_split_object() {
1134        let pdf = b"%PDF-1.4\n1 0 obj\n(endobj)\nendobj".to_vec();
1135        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1136
1137        assert_eq!(p.objects.len(), 1);
1138        assert_eq!(p.objects[0].number, 1);
1139        assert_eq!(p.objects[0].generation, 0);
1140        assert_eq!(p.objects[0].end, pdf.len() as u64);
1141        assert_eq!(count(&p, PhysicalKind::EndObj), 1);
1142        assert_eq!(count(&p, PhysicalKind::StreamData), 0);
1143        p.validate(pdf.len() as u64).unwrap();
1144    }
1145
1146    #[test]
1147    fn stream_data_with_keyword_spellings_stays_opaque() {
1148        let pdf =
1149            b"%PDF-1.4\n1 0 obj\n<< /Length 30 >>\nstream\nendobj stream bytes here\nendstream\nendobj"
1150                .to_vec();
1151        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1152
1153        assert_eq!(p.objects.len(), 1);
1154        assert_eq!(p.objects[0].number, 1);
1155        assert_eq!(p.objects[0].end, pdf.len() as u64);
1156        assert_eq!(count(&p, PhysicalKind::EndObj), 1);
1157        assert_eq!(count(&p, PhysicalKind::StreamData), 1);
1158
1159        // The opaque stream payload must contain the fake keywords verbatim.
1160        let data = p
1161            .spans
1162            .iter()
1163            .find(|s| s.kind == PhysicalKind::StreamData)
1164            .unwrap();
1165        let slice = &pdf[data.start as usize..(data.start + data.len) as usize];
1166        assert!(slice.windows(6).any(|w| w == b"endobj"));
1167        assert!(slice.windows(6).any(|w| w == b"stream"));
1168        p.validate(pdf.len() as u64).unwrap();
1169    }
1170
1171    #[test]
1172    fn two_objects_with_xref_trailer_and_startxref() {
1173        let pdf = b"%PDF-1.4\n1 0 obj\n<< >>\nendobj\n2 0 obj\n<< >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 3 >>\nstartxref\n99\n%%EOF".to_vec();
1174        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1175
1176        assert_eq!(p.objects.len(), 2);
1177        assert_eq!(p.objects[0].number, 1);
1178        assert_eq!(p.objects[1].number, 2);
1179        assert!(p.objects[0].end <= p.objects[1].start);
1180        assert_eq!(count(&p, PhysicalKind::XrefSection), 1);
1181        assert_eq!(count(&p, PhysicalKind::Trailer), 1);
1182        assert_eq!(p.startxref, [99]);
1183        assert_eq!(p.eofs.len(), 1);
1184        p.validate(pdf.len() as u64).unwrap();
1185    }
1186
1187    #[test]
1188    fn malformed_object_without_endobj_is_conservative() {
1189        let pdf = b"1 0 obj".to_vec();
1190        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1191
1192        assert!(p.objects.is_empty());
1193        assert_eq!(count(&p, PhysicalKind::ObjHeader), 1);
1194        assert_eq!(count(&p, PhysicalKind::EndObj), 0);
1195        assert_eq!(p.total_len(), pdf.len() as u64);
1196        p.validate(pdf.len() as u64).unwrap();
1197    }
1198
1199    #[test]
1200    fn missing_header_leaves_cover_total() {
1201        let pdf = b"1 0 obj\nendobj\n".to_vec();
1202        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1203
1204        assert_eq!(p.header, None);
1205        assert_eq!(count(&p, PhysicalKind::EndObj), 1);
1206        p.validate(pdf.len() as u64).unwrap();
1207    }
1208
1209    fn xorshift64(state: &mut u64) -> u64 {
1210        let mut x = *state;
1211        x ^= x << 13;
1212        x ^= x >> 7;
1213        x ^= x << 17;
1214        *state = x;
1215        x
1216    }
1217
1218    #[test]
1219    fn random_bytes_never_panic_and_keep_cover() {
1220        let mut state: u64 = 0x1234_5678_9abc_def0;
1221        for _ in 0..500 {
1222            let len = (xorshift64(&mut state) % 96) as usize;
1223            let mut buf = Vec::with_capacity(len);
1224            for _ in 0..len {
1225                buf.push((xorshift64(&mut state) & 0xff) as u8);
1226            }
1227            match scan(&buf, Limits::DEFAULT) {
1228                Ok(p) => {
1229                    p.validate(buf.len() as u64).unwrap();
1230                    assert_eq!(p.total_len(), buf.len() as u64);
1231                }
1232                Err(e) => {
1233                    // Any failure must be a typed, classified error.
1234                    let _ = e.class();
1235                }
1236            }
1237        }
1238    }
1239
1240    #[test]
1241    fn tiny_span_limit_is_resource_limit() {
1242        let pdf = canonical_pdf();
1243        let limits = Limits {
1244            max_pdf_spans: 1,
1245            ..Limits::DEFAULT
1246        };
1247        let err = scan(&pdf, limits).unwrap_err();
1248        assert_eq!(err.class(), ErrorClass::ResourceLimit);
1249    }
1250
1251    fn offset_of(hay: &[u8], needle: &[u8]) -> u64 {
1252        hay.windows(needle.len())
1253            .position(|w| w == needle)
1254            .expect("needle present") as u64
1255    }
1256
1257    fn slice_of(pdf: &[u8], s: PdfStreamSpan) -> &[u8] {
1258        &pdf[s.data_start as usize..(s.data_start + s.data_len) as usize]
1259    }
1260
1261    fn only_stream(p: &PdfPhysical) -> PdfStreamSpan {
1262        assert_eq!(p.streams.len(), 1, "expected exactly one stream");
1263        p.streams[0]
1264    }
1265
1266    #[test]
1267    fn direct_length_yields_exact_span() {
1268        let pdf =
1269            b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1270        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1271
1272        let s = only_stream(&p);
1273        assert_eq!(s.object, 1);
1274        assert_eq!(s.generation, 0);
1275        assert_eq!(s.length_source, LengthSource::Direct);
1276        assert_eq!(s.data_start, offset_of(&pdf, b"hello"));
1277        assert_eq!(s.data_len, 5);
1278        assert_eq!(slice_of(&pdf, s), b"hello");
1279
1280        let ds = p
1281            .spans
1282            .iter()
1283            .find(|sp| sp.kind == PhysicalKind::StreamData)
1284            .unwrap();
1285        assert_eq!(ds.start, s.data_start);
1286        assert_eq!(ds.len, s.data_len);
1287        p.validate(pdf.len() as u64).unwrap();
1288    }
1289
1290    #[test]
1291    fn length_including_trailing_eol_is_accepted() {
1292        let pdf =
1293            b"%PDF-1.5\n1 0 obj\n<< /Length 6 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1294        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1295        let s = only_stream(&p);
1296        assert_eq!(s.length_source, LengthSource::Direct);
1297        assert_eq!(slice_of(&pdf, s), b"hello\n");
1298        p.validate(pdf.len() as u64).unwrap();
1299    }
1300
1301    #[test]
1302    fn crlf_and_lf_after_stream_are_both_handled() {
1303        let crlf =
1304            b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\r\nstream\r\nhello\r\nendstream\r\nendobj\n"
1305                .to_vec();
1306        let p = scan(&crlf, Limits::DEFAULT).unwrap();
1307        let s = only_stream(&p);
1308        assert_eq!(s.length_source, LengthSource::Direct);
1309        assert_eq!(slice_of(&crlf, s), b"hello");
1310        assert_eq!(s.data_start, offset_of(&crlf, b"hello"));
1311        p.validate(crlf.len() as u64).unwrap();
1312
1313        let lf = b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1314        let p = scan(&lf, Limits::DEFAULT).unwrap();
1315        let s = only_stream(&p);
1316        assert_eq!(s.length_source, LengthSource::Direct);
1317        assert_eq!(slice_of(&lf, s), b"hello");
1318        p.validate(lf.len() as u64).unwrap();
1319    }
1320
1321    #[test]
1322    fn lone_cr_after_stream_falls_back() {
1323        let pdf =
1324            b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\nstream\rhello\r\nendstream\nendobj\n".to_vec();
1325        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1326        let s = only_stream(&p);
1327        assert_eq!(s.length_source, LengthSource::Fallback);
1328        assert!(slice_of(&pdf, s).windows(5).any(|w| w == b"hello"));
1329        p.validate(pdf.len() as u64).unwrap();
1330    }
1331
1332    #[test]
1333    fn indirect_length_is_resolved_forward_reference() {
1334        // Object 5 (the `/Length` target) appears *after* the stream object.
1335        let pdf =
1336            b"%PDF-1.5\n1 0 obj\n<< /Length 5 0 R >>\nstream\nhello\nendstream\nendobj\n5 0 obj\n5\nendobj\n"
1337                .to_vec();
1338        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1339        let s = only_stream(&p);
1340        assert_eq!(s.length_source, LengthSource::Indirect);
1341        assert_eq!(s.data_start, offset_of(&pdf, b"hello"));
1342        assert_eq!(s.data_len, 5);
1343        assert_eq!(slice_of(&pdf, s), b"hello");
1344        p.validate(pdf.len() as u64).unwrap();
1345    }
1346
1347    #[test]
1348    fn stream_filter_classification_reads_flate_and_absent() {
1349        let flate = b"%PDF-1.5\n1 0 obj\n<< /Length 5 /Filter /FlateDecode >>\nstream\nhello\nendstream\nendobj\n"
1350            .to_vec();
1351        let p = scan(&flate, Limits::DEFAULT).unwrap();
1352        let s = only_stream(&p);
1353        assert_eq!(s.filter, FilterClass::FlateDecode);
1354        assert_eq!(slice_of(&flate, s), b"hello");
1355        p.validate(flate.len() as u64).unwrap();
1356
1357        let absent =
1358            b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1359        let p = scan(&absent, Limits::DEFAULT).unwrap();
1360        let s = only_stream(&p);
1361        assert_eq!(s.filter, FilterClass::Absent);
1362        assert_eq!(slice_of(&absent, s), b"hello");
1363        p.validate(absent.len() as u64).unwrap();
1364    }
1365
1366    #[test]
1367    fn missing_length_uses_keyword_fallback() {
1368        let pdf = b"%PDF-1.5\n1 0 obj\n<< /Type /X >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1369        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1370        let s = only_stream(&p);
1371        assert_eq!(s.length_source, LengthSource::Missing);
1372        assert!(slice_of(&pdf, s).windows(5).any(|w| w == b"hello"));
1373        p.validate(pdf.len() as u64).unwrap();
1374    }
1375
1376    #[test]
1377    fn wrong_length_past_endstream_falls_back() {
1378        let pdf =
1379            b"%PDF-1.5\n1 0 obj\n<< /Length 100 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1380        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1381        let s = only_stream(&p);
1382        assert_eq!(s.length_source, LengthSource::Fallback);
1383        assert!(slice_of(&pdf, s).windows(5).any(|w| w == b"hello"));
1384        p.validate(pdf.len() as u64).unwrap();
1385    }
1386
1387    #[test]
1388    fn correct_length_beats_endstream_bytes_in_payload() {
1389        // The payload contains a standalone `endstream` token. The keyword-only
1390        // fallback would stop early; a verified `/Length` must win.
1391        let payload = b"endstream\nfoo";
1392        let pdf =
1393            b"%PDF-1.5\n1 0 obj\n<< /Length 13 >>\nstream\nendstream\nfoo\nendstream\nendobj\n"
1394                .to_vec();
1395        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1396        let s = only_stream(&p);
1397        assert_eq!(s.length_source, LengthSource::Direct);
1398        assert_eq!(s.data_start, offset_of(&pdf, b"endstream\nfoo"));
1399        assert_eq!(s.data_len, payload.len() as u64);
1400        assert_eq!(slice_of(&pdf, s), payload);
1401        p.validate(pdf.len() as u64).unwrap();
1402    }
1403
1404    #[test]
1405    fn two_revisions_have_correct_boundaries() {
1406        let r1 = b"%PDF-1.4\n1 0 obj\n<< >>\nendobj\n%%EOF\n";
1407        let r2 = b"2 0 obj\n<< >>\nendobj\n%%EOF";
1408        let mut pdf = Vec::new();
1409        pdf.extend_from_slice(r1);
1410        pdf.extend_from_slice(r2);
1411
1412        let eof_positions: Vec<u64> = pdf
1413            .windows(5)
1414            .enumerate()
1415            .filter(|(_, w)| *w == b"%%EOF")
1416            .map(|(i, _)| i as u64)
1417            .collect();
1418        assert_eq!(eof_positions.len(), 2);
1419
1420        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1421        assert_eq!(p.eofs.len(), 2);
1422        assert_eq!(p.revisions.len(), 2);
1423
1424        let rev1_end = eof_positions[0] + 5;
1425        let rev2_end = eof_positions[1] + 5;
1426        assert_eq!(
1427            p.revisions,
1428            vec![
1429                RevisionInfo {
1430                    index: 0,
1431                    start: 0,
1432                    end: rev1_end,
1433                    startxref: None,
1434                    prev: None,
1435                },
1436                RevisionInfo {
1437                    index: 1,
1438                    start: rev1_end + 1,
1439                    end: rev2_end,
1440                    startxref: None,
1441                    prev: None,
1442                },
1443            ]
1444        );
1445        assert_eq!(rev2_end, pdf.len() as u64);
1446        assert_eq!(p.objects.len(), 2);
1447        p.validate(pdf.len() as u64).unwrap();
1448    }
1449
1450    #[test]
1451    fn classic_xref_revision_records_startxref_and_no_prev() {
1452        let pdf = canonical_pdf();
1453        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1454        assert_eq!(p.revisions.len(), 1);
1455        let r = p.revisions[0];
1456        assert_eq!(r.index, 0);
1457        assert_eq!(r.start, 0);
1458        assert_eq!(r.end, pdf.len() as u64);
1459        assert_eq!(r.startxref, Some(321));
1460        assert_eq!(r.prev, None);
1461        p.validate(pdf.len() as u64).unwrap();
1462    }
1463
1464    #[test]
1465    fn incremental_classic_trailer_prev_resolves_to_first_xref() {
1466        let rev1 = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\nxref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n9\n%%EOF\n";
1467        let x1 = rev1
1468            .windows(4)
1469            .position(|w| w == b"xref")
1470            .expect("xref present") as u64;
1471        let rev2 = format!(
1472            "2 0 obj\n<< /Type /Pages >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000042 00000 n \ntrailer\n<< /Size 3 /Prev {x1} /Root 1 0 R >>\nstartxref\n777\n%%EOF"
1473        );
1474        let mut pdf = Vec::new();
1475        pdf.extend_from_slice(rev1);
1476        pdf.extend_from_slice(rev2.as_bytes());
1477
1478        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1479        assert_eq!(p.revisions.len(), 2);
1480        assert_eq!(p.revisions[0].index, 0);
1481        assert_eq!(p.revisions[0].startxref, Some(9));
1482        assert_eq!(p.revisions[0].prev, None);
1483        assert_eq!(p.revisions[1].index, 1);
1484        assert_eq!(p.revisions[1].startxref, Some(777));
1485        assert_eq!(p.revisions[1].prev, Some(x1));
1486        p.validate(pdf.len() as u64).unwrap();
1487    }
1488
1489    #[test]
1490    fn xref_stream_anchor_prev_reference_is_resolved() {
1491        let rev1 = b"%PDF-1.5\n1 0 obj\n<< >>\nendobj\nstartxref\n0\n%%EOF\n";
1492        let rev2 = b"2 0 obj\n<< /Type /XRef /Prev 3 0 R >>\nendobj\n3 0 obj\n<< >>\nendobj\nstartxref\n0\n%%EOF";
1493        let mut pdf = Vec::new();
1494        pdf.extend_from_slice(rev1);
1495        pdf.extend_from_slice(rev2);
1496        let obj3_start = pdf
1497            .windows(8)
1498            .position(|w| w == b"3 0 obj\n")
1499            .expect("object 3 present") as u64;
1500
1501        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1502        assert_eq!(p.revisions.len(), 2);
1503        assert_eq!(p.revisions[0].prev, None);
1504        assert_eq!(p.revisions[1].prev, Some(obj3_start));
1505        assert_eq!(
1506            p.objects.iter().find(|o| o.number == 2).unwrap().role,
1507            ObjRole::XRefStream
1508        );
1509        p.validate(pdf.len() as u64).unwrap();
1510    }
1511
1512    #[test]
1513    fn object_roles_are_classified_from_leading_dict() {
1514        let pdf = b"%PDF-1.5\n1 0 obj\n<< /Type /Catalog >>\nendobj\n2 0 obj\n<< /Type /XRef >>\nendobj\n3 0 obj\n<< /Type /ObjStm /N 0 >>\nendobj\n4 0 obj\n<< /Foo /Bar >>\nendobj\n5 0 obj\n<< /Type 5 >>\nendobj\n6 0 obj\n42\nendobj\n7 0 obj\n<< /Foo ( /Type /XRef ) >>\nendobj\n".to_vec();
1515        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1516        let role = |n: u64| p.objects.iter().find(|o| o.number == n).unwrap().role;
1517        assert_eq!(role(1), ObjRole::Generic);
1518        assert_eq!(role(2), ObjRole::XRefStream);
1519        assert_eq!(role(3), ObjRole::ObjectStream);
1520        assert_eq!(role(4), ObjRole::Generic);
1521        assert_eq!(role(5), ObjRole::Generic);
1522        assert_eq!(role(6), ObjRole::Generic);
1523        assert_eq!(role(7), ObjRole::Generic);
1524        p.validate(pdf.len() as u64).unwrap();
1525    }
1526
1527    #[test]
1528    fn random_inputs_keep_streams_and_revisions_consistent() {
1529        let mut state: u64 = 0xdead_beef_cafe_f00d;
1530        for _ in 0..500 {
1531            let len = (xorshift64(&mut state) % 128) as usize;
1532            let mut buf = Vec::with_capacity(len);
1533            for _ in 0..len {
1534                buf.push((xorshift64(&mut state) & 0xff) as u8);
1535            }
1536            let p = scan(&buf, Limits::DEFAULT).unwrap();
1537            p.validate(buf.len() as u64).unwrap();
1538            for s in &p.streams {
1539                assert!(s.data_start + s.data_len <= buf.len() as u64);
1540            }
1541            for r in &p.revisions {
1542                assert!(r.start <= r.end && r.end <= buf.len() as u64);
1543            }
1544        }
1545    }
1546}