Skip to main content

zpdf_parser/
xref.rs

1use std::collections::HashMap;
2
3use zpdf_core::{Error, ObjectId, ParseLimits, PdfDict, PdfObject, Result};
4
5use crate::lexer::Lexer;
6
7#[derive(Debug, Clone)]
8pub enum XrefEntry {
9    InUse {
10        offset: u64,
11        gen: u16,
12    },
13    Free {
14        next: u32,
15        gen: u16,
16    },
17    Compressed {
18        stream_obj: u32,
19        index_in_stream: u32,
20    },
21}
22
23#[derive(Debug, Clone, Default)]
24pub struct XrefTable {
25    entries: HashMap<ObjectId, XrefEntry>,
26}
27
28impl XrefTable {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    pub fn get(&self, id: ObjectId) -> Option<&XrefEntry> {
34        self.entries.get(&id)
35    }
36
37    pub fn insert(&mut self, id: ObjectId, entry: XrefEntry) {
38        self.entries.entry(id).or_insert(entry);
39    }
40
41    /// Insert overwriting any existing entry. Used by tail-scan recovery, where a
42    /// later byte offset for the same ObjectId supersedes an earlier one
43    /// (incremental-update semantics). The regular `insert` is first-wins because
44    /// the /Prev chain walks newest-to-oldest.
45    pub fn insert_overwrite(&mut self, id: ObjectId, entry: XrefEntry) {
46        self.entries.insert(id, entry);
47    }
48
49    pub fn len(&self) -> usize {
50        self.entries.len()
51    }
52
53    pub fn is_empty(&self) -> bool {
54        self.entries.is_empty()
55    }
56
57    pub fn object_ids(&self) -> impl Iterator<Item = ObjectId> + '_ {
58        self.entries.keys().copied()
59    }
60}
61
62pub fn parse_xref_and_trailer(data: &[u8], limits: &ParseLimits) -> Result<(XrefTable, PdfDict)> {
63    let startxref_offset = find_startxref(data)?;
64    let mut table = XrefTable::new();
65
66    let xref_offset = startxref_offset;
67    let (trailer, next_prev) = parse_xref_section(data, xref_offset, &mut table, limits)?;
68
69    // Follow /Prev chain for incremental updates. Track visited offsets so a
70    // malformed /Prev cycle (a section pointing at itself, or two pointing at
71    // each other) terminates instead of looping forever.
72    let mut visited = std::collections::HashSet::new();
73    visited.insert(xref_offset);
74    // Hybrid-reference file: parse the trailer's /XRefStm BEFORE following
75    // /Prev, so first-wins insertion yields main table > XRefStm > /Prev.
76    parse_hybrid_xrefstm(data, &trailer, &mut table, limits, &mut visited);
77    let mut prev = next_prev;
78    while let Some(prev_offset) = prev {
79        if !visited.insert(prev_offset as usize) {
80            break;
81        }
82        let (section_trailer, next) =
83            parse_xref_section(data, prev_offset as usize, &mut table, limits)?;
84        parse_hybrid_xrefstm(data, &section_trailer, &mut table, limits, &mut visited);
85        prev = next;
86    }
87
88    Ok((table, trailer))
89}
90
91/// Hybrid-reference files (ISO 32000-1, 7.5.8.4): a traditional trailer may
92/// carry `/XRefStm`, the byte offset of a cross-reference *stream* holding the
93/// entries (typically the compressed-object ones) that pre-1.5 readers ignore.
94/// The stream is parsed after the section that referenced it but before that
95/// section's /Prev; with first-wins insertion this gives the spec precedence
96/// main-table > XRefStm > /Prev chain. Never fatal: a broken /XRefStm only
97/// loses the hybrid entries.
98fn parse_hybrid_xrefstm(
99    data: &[u8],
100    trailer: &PdfDict,
101    table: &mut XrefTable,
102    limits: &ParseLimits,
103    visited: &mut std::collections::HashSet<usize>,
104) {
105    let Some(PdfObject::Integer(off)) = trailer.get("XRefStm") else {
106        return;
107    };
108    let Ok(off) = usize::try_from(*off) else {
109        tracing::warn!("/XRefStm offset {off} is negative; ignoring");
110        return;
111    };
112    // Guard against /XRefStm cycles via the same visited set as /Prev.
113    if !visited.insert(off) {
114        return;
115    }
116    if let Err(e) = parse_xref_stream(data, off, table, limits) {
117        tracing::warn!("failed to parse /XRefStm at offset {off}: {e}");
118    }
119}
120
121fn parse_xref_section(
122    data: &[u8],
123    offset: usize,
124    table: &mut XrefTable,
125    limits: &ParseLimits,
126) -> Result<(PdfDict, Option<u64>)> {
127    // Guard against a garbage `startxref`/`/Prev` offset pointing past EOF.
128    // Returning Err (rather than panicking on the slice below) lets the caller
129    // fall back to tail-scan recovery.
130    if offset >= data.len() {
131        return Err(Error::InvalidXref(offset as u64));
132    }
133    if data[offset..].starts_with(b"xref") {
134        parse_traditional_xref(data, offset, table, limits)
135    } else {
136        parse_xref_stream(data, offset, table, limits)
137    }
138}
139
140fn parse_xref_stream(
141    data: &[u8],
142    offset: usize,
143    table: &mut XrefTable,
144    limits: &ParseLimits,
145) -> Result<(PdfDict, Option<u64>)> {
146    use crate::filters;
147    use crate::object_parser::ObjectParser;
148
149    let parser = ObjectParser::new(data, limits);
150    let obj = parser.parse_indirect_at(offset)?;
151    let stream = match obj {
152        PdfObject::Stream(s) => s,
153        _ => return Err(Error::InvalidXref(offset as u64)),
154    };
155
156    let dict = &stream.dict;
157    if dict.get_name("Type").unwrap_or("") != "XRef" {
158        return Err(Error::InvalidXref(offset as u64));
159    }
160
161    let size = dict.get_i64("Size")? as u32;
162
163    // /W [w1 w2 w3] — field widths. Attacker-controlled: a negative width cast
164    // to usize would explode entry_size, and widths above 8 cannot fit the u64
165    // accumulator in read_field; reject both with a clean error.
166    let w_arr = dict.get_array("W")?;
167    if w_arr.len() != 3 {
168        return Err(Error::InvalidXref(offset as u64));
169    }
170    let field_width = |obj: &PdfObject| -> Result<usize> {
171        let w = obj.as_i64()?;
172        if !(0..=8).contains(&w) {
173            return Err(Error::InvalidXref(offset as u64));
174        }
175        Ok(w as usize)
176    };
177    let w1 = field_width(&w_arr[0])?;
178    let w2 = field_width(&w_arr[1])?;
179    let w3 = field_width(&w_arr[2])?;
180    let entry_size = w1 + w2 + w3;
181    if entry_size == 0 {
182        return Err(Error::InvalidXref(offset as u64));
183    }
184
185    // Decode stream data using explicit limits for H1 security fix
186    let decoded = filters::decode_stream_with_limits(&stream.data, dict, limits)?;
187
188    // /Index [start count start count ...] — subsection ranges (optional)
189    let index_ranges: Vec<(u32, u32)> = if let Ok(idx_arr) = dict.get_array("Index") {
190        idx_arr
191            .chunks(2)
192            .filter_map(|pair| {
193                if pair.len() == 2 {
194                    Some((pair[0].as_i64().ok()? as u32, pair[1].as_i64().ok()? as u32))
195                } else {
196                    None
197                }
198            })
199            .collect()
200    } else {
201        vec![(0, size)]
202    };
203
204    let mut pos = 0usize;
205    for &(start, count) in &index_ranges {
206        // H3 Fix: Validate range end doesn't overflow u32 before processing
207        let _range_end = start
208            .checked_add(count)
209            .ok_or(Error::InvalidXref(offset as u64))?;
210
211        // H3 Fix: Check total entry count against max_objects before processing range
212        let new_total = table
213            .len()
214            .checked_add(count as usize)
215            .ok_or(Error::InvalidXref(offset as u64))?;
216        if new_total > limits.max_objects as usize {
217            return Err(Error::InvalidXref(offset as u64));
218        }
219
220        for i in 0..count {
221            if pos + entry_size > decoded.len() {
222                break;
223            }
224            let obj_num = start + i;
225
226            let field1 = read_field(&decoded[pos..], w1);
227            let field2 = read_field(&decoded[pos + w1..], w2);
228            let field3 = read_field(&decoded[pos + w1 + w2..], w3);
229            pos += entry_size;
230
231            let entry_type = if w1 == 0 { 1 } else { field1 as u8 };
232            let id = ObjectId(obj_num, field3 as u16);
233
234            match entry_type {
235                0 => {
236                    table.insert(
237                        id,
238                        XrefEntry::Free {
239                            next: field2 as u32,
240                            gen: field3 as u16,
241                        },
242                    );
243                }
244                1 => {
245                    table.insert(
246                        id,
247                        XrefEntry::InUse {
248                            offset: field2,
249                            gen: field3 as u16,
250                        },
251                    );
252                }
253                2 => {
254                    table.insert(
255                        ObjectId(obj_num, 0),
256                        XrefEntry::Compressed {
257                            stream_obj: field2 as u32,
258                            index_in_stream: field3 as u32,
259                        },
260                    );
261                }
262                _ => {}
263            }
264        }
265    }
266
267    // The xref stream dict itself serves as the trailer
268    let trailer = dict.clone();
269    let prev = trailer.get("Prev").and_then(|obj| match obj {
270        PdfObject::Integer(n) => Some(*n as u64),
271        _ => None,
272    });
273
274    Ok((trailer, prev))
275}
276
277fn read_field(data: &[u8], width: usize) -> u64 {
278    let mut val = 0u64;
279    for &byte in &data[..width] {
280        val = (val << 8) | byte as u64;
281    }
282    val
283}
284
285fn parse_traditional_xref(
286    data: &[u8],
287    offset: usize,
288    table: &mut XrefTable,
289    limits: &ParseLimits,
290) -> Result<(PdfDict, Option<u64>)> {
291    let mut pos = offset + 4; // skip "xref"
292    skip_eol(data, &mut pos);
293
294    // Parse subsections
295    loop {
296        skip_whitespace(data, &mut pos);
297
298        // Guard against a malformed/truncated table that ran the cursor off the
299        // end (e.g. a subsection /count larger than the entries that fit).
300        // Returning Err routes to tail-scan recovery instead of panicking.
301        if pos >= data.len() {
302            return Err(Error::InvalidXref(pos as u64));
303        }
304
305        if data[pos..].starts_with(b"trailer") {
306            pos += 7;
307            break;
308        }
309
310        // Read: <first_obj_num> <count>
311        let (first_obj, count) = parse_subsection_header(data, &mut pos)?;
312
313        // L7 Fix: Validate subsection range doesn't overflow object ID space
314        let range_end = first_obj
315            .checked_add(count)
316            .ok_or(Error::InvalidXref(pos as u64))?;
317        if range_end > limits.max_objects {
318            return Err(Error::InvalidXref(pos as u64));
319        }
320
321        // L7 Fix: Check total entry count against limits before processing
322        let new_total = table
323            .len()
324            .checked_add(count as usize)
325            .ok_or(Error::InvalidXref(pos as u64))?;
326        if new_total > limits.max_objects as usize {
327            return Err(Error::InvalidXref(pos as u64));
328        }
329
330        for i in 0..count {
331            skip_whitespace(data, &mut pos);
332            // An entry is nominally 20 bytes ("nnnnnnnnnn ggggg n \r\n"), but
333            // real files contain 19-byte variants (lone \n or \r terminator).
334            // Parse by tokens, not a fixed stride, so short entries cannot
335            // desync the rest of the table. A truncated/corrupt entry Errs out
336            // to tail-scan recovery rather than under/overflowing.
337            let (entry_offset, gen, in_use) = parse_xref_entry_at(data, &mut pos)?;
338            let id = ObjectId(first_obj + i, gen);
339
340            if in_use {
341                table.insert(
342                    id,
343                    XrefEntry::InUse {
344                        offset: entry_offset,
345                        gen,
346                    },
347                );
348            } else {
349                table.insert(
350                    id,
351                    XrefEntry::Free {
352                        next: entry_offset as u32,
353                        gen,
354                    },
355                );
356            }
357        }
358    }
359
360    // Parse trailer dictionary
361    let mut lex = Lexer::new(data, pos, limits);
362    let trailer_obj = lex.next_token()?;
363    let trailer = match trailer_obj {
364        PdfObject::Dict(d) => d,
365        _ => return Err(Error::InvalidXref(pos as u64)),
366    };
367
368    let prev = trailer.get("Prev").and_then(|obj| match obj {
369        PdfObject::Integer(n) => Some(*n as u64),
370        _ => None,
371    });
372
373    Ok((trailer, prev))
374}
375
376fn find_startxref(data: &[u8]) -> Result<usize> {
377    // Search the WHOLE buffer for the LAST `startxref` rather than only the
378    // final 1 KiB. Real files frequently carry substantial trailing bytes after
379    // the last %%EOF (truncated incremental appends, fuzzer junk, appended
380    // objects); a tail-only window misses the real startxref and forfeits an
381    // otherwise-valid xref. This runs once per open, so the full rposition is
382    // affordable, and a wrong hit still falls through to tail-scan recovery.
383    let marker = b"startxref";
384    let marker_pos = data
385        .windows(marker.len())
386        .rposition(|w| w == marker)
387        .ok_or(Error::InvalidXref(0))?;
388
389    let after_marker = marker_pos + marker.len();
390    let num_start = data[after_marker..]
391        .iter()
392        .position(|b| b.is_ascii_digit())
393        .ok_or(Error::InvalidXref(0))?;
394
395    let num_bytes = &data[after_marker + num_start..];
396    let num_end = num_bytes
397        .iter()
398        .position(|b| !b.is_ascii_digit())
399        .unwrap_or(num_bytes.len());
400
401    let offset_str =
402        std::str::from_utf8(&num_bytes[..num_end]).map_err(|_| Error::InvalidXref(0))?;
403    let offset: usize = offset_str.parse().map_err(|_| Error::InvalidXref(0))?;
404
405    Ok(offset)
406}
407
408fn parse_subsection_header(data: &[u8], pos: &mut usize) -> Result<(u32, u32)> {
409    let start = *pos;
410    while *pos < data.len() && data[*pos].is_ascii_digit() {
411        *pos += 1;
412    }
413    let first: u32 = std::str::from_utf8(&data[start..*pos])
414        .map_err(|_| Error::InvalidXref(start as u64))?
415        .parse()
416        .map_err(|_| Error::InvalidXref(start as u64))?;
417
418    skip_whitespace(data, pos);
419
420    let count_start = *pos;
421    while *pos < data.len() && data[*pos].is_ascii_digit() {
422        *pos += 1;
423    }
424    let count: u32 = std::str::from_utf8(&data[count_start..*pos])
425        .map_err(|_| Error::InvalidXref(count_start as u64))?
426        .parse()
427        .map_err(|_| Error::InvalidXref(count_start as u64))?;
428
429    skip_eol(data, pos);
430    Ok((first, count))
431}
432
433/// Parse a single traditional xref entry ("nnnnnnnnnn ggggg n") at `*pos`,
434/// advancing the cursor just past the type letter. Field widths are not
435/// assumed: digit runs of any length and any amount of inter-field whitespace
436/// are accepted, which tolerates the 19-byte entries some writers emit.
437fn parse_xref_entry_at(data: &[u8], pos: &mut usize) -> Result<(u64, u16, bool)> {
438    let start = *pos as u64;
439    let offset = read_decimal(data, pos).ok_or(Error::InvalidXref(start))?;
440    skip_whitespace(data, pos);
441    let gen = read_decimal(data, pos)
442        .and_then(|g| u16::try_from(g).ok())
443        .ok_or(Error::InvalidXref(start))?;
444    skip_whitespace(data, pos);
445    let in_use = match data.get(*pos) {
446        Some(b'n') => true,
447        Some(b'f') => false,
448        _ => return Err(Error::InvalidXref(start)),
449    };
450    *pos += 1;
451    Ok((offset, gen, in_use))
452}
453
454/// Read a run of ASCII digits at `*pos` as a u64, advancing past it.
455/// `None` if there is no digit at `*pos` or the value overflows.
456fn read_decimal(data: &[u8], pos: &mut usize) -> Option<u64> {
457    let start = *pos;
458    while *pos < data.len() && data[*pos].is_ascii_digit() {
459        *pos += 1;
460    }
461    if *pos == start {
462        return None;
463    }
464    std::str::from_utf8(&data[start..*pos]).ok()?.parse().ok()
465}
466
467fn skip_whitespace(data: &[u8], pos: &mut usize) {
468    while *pos < data.len() && matches!(data[*pos], b' ' | b'\t' | b'\r' | b'\n') {
469        *pos += 1;
470    }
471}
472
473fn skip_eol(data: &[u8], pos: &mut usize) {
474    while *pos < data.len() && matches!(data[*pos], b' ' | b'\t' | b'\r' | b'\n') {
475        *pos += 1;
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn find_startxref_offset() {
485        let data = b"%PDF-1.4\n...lots of content...\nstartxref\n1234\n%%EOF";
486        let offset = find_startxref(data).unwrap();
487        assert_eq!(offset, 1234);
488    }
489
490    #[test]
491    fn parse_xref_entry_in_use() {
492        let mut pos = 0usize;
493        let (offset, gen, in_use) =
494            parse_xref_entry_at(b"0000000010 00000 n \r\n", &mut pos).unwrap();
495        assert_eq!(offset, 10);
496        assert_eq!(gen, 0);
497        assert!(in_use);
498        assert_eq!(pos, 18, "cursor stops just past the type letter");
499    }
500
501    #[test]
502    fn parse_xref_entry_free() {
503        let mut pos = 0usize;
504        let (offset, gen, in_use) =
505            parse_xref_entry_at(b"0000000000 65535 f \r\n", &mut pos).unwrap();
506        assert_eq!(offset, 0);
507        assert_eq!(gen, 65535);
508        assert!(!in_use);
509    }
510
511    #[test]
512    fn parse_xref_entry_truncated_errors() {
513        let mut pos = 0usize;
514        assert!(parse_xref_entry_at(b"0000000010 000", &mut pos).is_err());
515    }
516
517    #[test]
518    fn traditional_xref_with_19_byte_entries() {
519        // Entries terminated by a lone \n (19 bytes) must not desync the table.
520        let mut d = Vec::new();
521        d.extend_from_slice(b"%PDF-1.4\n");
522        let off1 = d.len();
523        d.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
524        let off2 = d.len();
525        d.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
526        let xref_off = d.len();
527        d.extend_from_slice(b"xref\n0 3\n");
528        d.extend_from_slice(b"0000000000 65535 f\n"); // 19 bytes
529        d.extend_from_slice(format!("{off1:010} 00000 n\n").as_bytes()); // 19 bytes
530        d.extend_from_slice(format!("{off2:010} 00000 n\n").as_bytes()); // 19 bytes
531        d.extend_from_slice(
532            format!("trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
533                .as_bytes(),
534        );
535
536        let (table, trailer) = parse_xref_and_trailer(&d, &ParseLimits::default()).unwrap();
537        assert_eq!(trailer.get_ref("Root").unwrap(), ObjectId(1, 0));
538        match table.get(ObjectId(1, 0)).unwrap() {
539            XrefEntry::InUse { offset, .. } => assert_eq!(*offset as usize, off1),
540            other => panic!("expected InUse, got {other:?}"),
541        }
542        match table.get(ObjectId(2, 0)).unwrap() {
543            XrefEntry::InUse { offset, .. } => assert_eq!(*offset as usize, off2),
544            other => panic!("expected InUse, got {other:?}"),
545        }
546    }
547
548    /// Build a minimal xref-stream object at offset 0 with the given /W array
549    /// and raw (unfiltered) entry data.
550    fn xref_stream_bytes(w: &str, size: u32, index: &str, body: &[u8]) -> Vec<u8> {
551        let mut d = format!(
552            "9 0 obj\n<< /Type /XRef /Size {size} /W {w} {index} /Length {} >>\nstream\n",
553            body.len()
554        )
555        .into_bytes();
556        d.extend_from_slice(body);
557        d.extend_from_slice(b"\nendstream\nendobj\n");
558        d
559    }
560
561    #[test]
562    fn xref_stream_rejects_negative_w_width() {
563        let d = xref_stream_bytes("[1 -2 2]", 1, "", &[]);
564        let mut table = XrefTable::new();
565        assert!(parse_xref_stream(&d, 0, &mut table, &ParseLimits::default()).is_err());
566    }
567
568    #[test]
569    fn xref_stream_rejects_oversized_w_width() {
570        let d = xref_stream_bytes("[9 4 2]", 1, "", &[]);
571        let mut table = XrefTable::new();
572        assert!(parse_xref_stream(&d, 0, &mut table, &ParseLimits::default()).is_err());
573    }
574
575    #[test]
576    fn xref_stream_rejects_zero_entry_size() {
577        let d = xref_stream_bytes("[0 0 0]", 1, "", &[]);
578        let mut table = XrefTable::new();
579        assert!(parse_xref_stream(&d, 0, &mut table, &ParseLimits::default()).is_err());
580    }
581
582    #[test]
583    fn hybrid_xrefstm_is_parsed_with_correct_precedence() {
584        // Hybrid-reference layout: the traditional table covers objects 0,1,4;
585        // the trailer's /XRefStm points at an xref stream that covers 4 and 5.
586        // Object 5 must come from the stream; object 4 must keep the
587        // main-table offset (main table wins over /XRefStm).
588        let mut d = Vec::new();
589        d.extend_from_slice(b"%PDF-1.4\n");
590        let off1 = d.len();
591        d.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
592        let off4_table = d.len();
593        d.extend_from_slice(b"4 0 obj\n<< /Marker /FromTable >>\nendobj\n");
594        let off4_stm = d.len();
595        d.extend_from_slice(b"4 0 obj\n<< /Marker /FromStm >>\nendobj\n");
596        let off5 = d.len();
597        d.extend_from_slice(b"5 0 obj\n<< /Marker /StmOnly >>\nendobj\n");
598
599        // Xref stream (object 6): /W [1 4 2], /Index [4 2], raw (no filter).
600        let mut body = Vec::new();
601        for (off, gen) in [(off4_stm as u32, 0u16), (off5 as u32, 0)] {
602            body.push(1u8); // type 1: in use
603            body.extend_from_slice(&off.to_be_bytes());
604            body.extend_from_slice(&gen.to_be_bytes());
605        }
606        let off6 = d.len();
607        d.extend_from_slice(
608            format!(
609                "6 0 obj\n<< /Type /XRef /Size 7 /W [1 4 2] /Index [4 2] /Length {} >>\nstream\n",
610                body.len()
611            )
612            .as_bytes(),
613        );
614        d.extend_from_slice(&body);
615        d.extend_from_slice(b"\nendstream\nendobj\n");
616
617        let xref_off = d.len();
618        d.extend_from_slice(b"xref\n0 2\n0000000000 65535 f \n");
619        d.extend_from_slice(format!("{off1:010} 00000 n \n").as_bytes());
620        d.extend_from_slice(b"4 1\n");
621        d.extend_from_slice(format!("{off4_table:010} 00000 n \n").as_bytes());
622        d.extend_from_slice(
623            format!(
624                "trailer\n<< /Size 7 /Root 1 0 R /XRefStm {off6} >>\nstartxref\n{xref_off}\n%%EOF\n"
625            )
626            .as_bytes(),
627        );
628
629        let (table, trailer) = parse_xref_and_trailer(&d, &ParseLimits::default()).unwrap();
630        assert_eq!(trailer.get_ref("Root").unwrap(), ObjectId(1, 0));
631        // Object 5 exists only via the /XRefStm.
632        match table.get(ObjectId(5, 0)).unwrap() {
633            XrefEntry::InUse { offset, .. } => assert_eq!(*offset as usize, off5),
634            other => panic!("expected InUse from XRefStm, got {other:?}"),
635        }
636        // Object 4: the traditional table's offset wins over the stream's.
637        match table.get(ObjectId(4, 0)).unwrap() {
638            XrefEntry::InUse { offset, .. } => assert_eq!(*offset as usize, off4_table),
639            other => panic!("expected InUse, got {other:?}"),
640        }
641
642        // End-to-end: the document opens and the stream-only object resolves.
643        let file = crate::PdfFile::parse(d).unwrap();
644        let o5 = file.resolve(ObjectId(5, 0)).unwrap();
645        assert_eq!(o5.as_dict().unwrap().get_name("Marker").unwrap(), "StmOnly");
646        let o4 = file.resolve(ObjectId(4, 0)).unwrap();
647        assert_eq!(
648            o4.as_dict().unwrap().get_name("Marker").unwrap(),
649            "FromTable"
650        );
651    }
652}