Skip to main content

pdfboss_aio/
stream.rs

1//! A lazy element stream mirroring the sync iterator's ordering and
2//! salvage semantics: physical elements first (header when present,
3//! objects by offset with object-stream members after their container,
4//! xref sections in chain order, one merged trailer, startxref, eof),
5//! then logical elements in document order. Nothing is fetched, parsed or
6//! decoded before it is yielded; logical elements are prepared one page
7//! at a time.
8
9use std::collections::{HashMap, VecDeque};
10use std::pin::Pin;
11use std::task::{Context, Poll};
12
13use futures_core::stream::BoxStream;
14use pdfboss_core::elements::{Element, ElementOpts};
15use pdfboss_core::xref::XrefEntry;
16use pdfboss_core::{Dict, Name, ObjRef, Object};
17
18use crate::document::AsyncDocument;
19use crate::error::{Error, Result};
20
21/// Async counterpart of core's sync element iterator. `Send + 'static`
22/// (it owns a cheap `Arc` clone of the document, not a borrow of it), so it
23/// can drive work on multi-threaded runtimes and outlive the call that
24/// created it — e.g. crossing a PyO3 binding boundary.
25pub struct ElementStream {
26    inner: BoxStream<'static, Result<Element>>,
27}
28
29impl futures_core::Stream for ElementStream {
30    type Item = Result<Element>;
31
32    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
33        self.get_mut().inner.as_mut().poll_next(cx)
34    }
35}
36
37/// One unit of deferred work; producing an element (or a batch of logical
38/// elements) may fetch and parse, which is exactly what laziness defers.
39enum WorkItem {
40    Header,
41    InFile {
42        r: ObjRef,
43        offset: u64,
44    },
45    InStream {
46        r: ObjRef,
47        container: u32,
48        index: u32,
49    },
50    Section(usize),
51    Trailer,
52    StartXref,
53    Eof,
54    Page(usize),
55    PageResources(usize),
56    PageContentOps(usize),
57}
58
59/// Owns a cheap `Arc` clone of the document (not a borrow): this is what
60/// lets [`ElementStream`] be `'static`, un-tethered from the `AsyncDocument`
61/// that created it.
62struct StreamState {
63    doc: AsyncDocument,
64    work: VecDeque<WorkItem>,
65    pending: VecDeque<Result<Element>>,
66}
67
68/// Builds the stream: the worklist is computed synchronously from state
69/// the open flow already holds (no fetches); each work item is executed
70/// only when the consumer polls for it.
71pub(crate) fn element_stream(doc: &AsyncDocument, opts: ElementOpts) -> ElementStream {
72    let state = StreamState {
73        doc: doc.clone(),
74        work: build_worklist(doc, &opts),
75        pending: VecDeque::new(),
76    };
77    ElementStream {
78        inner: Box::pin(futures_util::stream::unfold(
79            state,
80            |mut state| async move {
81                loop {
82                    if let Some(item) = state.pending.pop_front() {
83                        return Some((item, state));
84                    }
85                    let work = state.work.pop_front()?;
86                    produce(&mut state, work).await;
87                }
88            },
89        )),
90    }
91}
92
93/// One entry scheduled for physical object iteration, pre-sorted by file
94/// position — the async mirror of core's `elements::OrderEntry`
95/// (`build_order`), which is the parity arbiter for this ordering.
96struct OrderEntry {
97    num: u32,
98    entry: XrefEntry,
99    /// The object's own offset, or its container's offset for members.
100    sort_offset: u64,
101    /// 0 for in-file objects; 1 + member index for object-stream members,
102    /// so members directly follow their container. Members whose container
103    /// is missing, free, or itself in a stream sort last (`u64::MAX`
104    /// `sort_offset`, checked at build time, per adopted CORE-PARITY rule).
105    sort_member: u64,
106}
107
108/// Lays out the element order up front (cheap: xref entries and section
109/// records are already in memory). Object order matches core's
110/// `elements::build_order` exactly: `(sort_offset, sort_member, num)` —
111/// in-file objects at their own offset (member 0), object-stream members
112/// at their container's offset (`1 + index`) directly after it, and
113/// members whose container is missing/free/itself-in-a-stream sorted last
114/// (`u64::MAX`), where they yield `Err` once produced.
115fn build_worklist(doc: &AsyncDocument, opts: &ElementOpts) -> VecDeque<WorkItem> {
116    let mut work = VecDeque::new();
117    if opts.physical {
118        work.push_back(WorkItem::Header);
119        let entries = doc.xref_entries();
120        let by_num: HashMap<u32, XrefEntry> = entries.iter().copied().collect();
121        let mut order: Vec<OrderEntry> = entries
122            .into_iter()
123            .filter_map(|(num, entry)| match entry {
124                XrefEntry::Free => None,
125                XrefEntry::InFile { offset, .. } => Some(OrderEntry {
126                    num,
127                    entry,
128                    sort_offset: offset,
129                    sort_member: 0,
130                }),
131                XrefEntry::InStream { stream_num, index } => {
132                    let sort_offset = match by_num.get(&stream_num) {
133                        Some(XrefEntry::InFile { offset, .. }) => *offset,
134                        // Missing, free, or itself-in-a-stream containers
135                        // have no bytes: their members sort last and yield
136                        // Err once `produce` tries to fetch them.
137                        _ => u64::MAX,
138                    };
139                    Some(OrderEntry {
140                        num,
141                        entry,
142                        sort_offset,
143                        sort_member: 1 + u64::from(index),
144                    })
145                }
146            })
147            .collect();
148        order.sort_by_key(|e| (e.sort_offset, e.sort_member, e.num));
149        for e in order {
150            match e.entry {
151                XrefEntry::InFile { offset, gen } => {
152                    work.push_back(WorkItem::InFile {
153                        r: ObjRef { num: e.num, gen },
154                        offset,
155                    });
156                }
157                XrefEntry::InStream { stream_num, index } => {
158                    work.push_back(WorkItem::InStream {
159                        r: ObjRef { num: e.num, gen: 0 },
160                        container: stream_num,
161                        index,
162                    });
163                }
164                XrefEntry::Free => unreachable!("filtered out above"),
165            }
166        }
167        // Sections in chain order (as stored by the open flow), then the
168        // single merged trailer (adopted rule 4), startxref, eof.
169        for section_index in 0..doc.sections().len() {
170            work.push_back(WorkItem::Section(section_index));
171        }
172        work.push_back(WorkItem::Trailer);
173        work.push_back(WorkItem::StartXref);
174        work.push_back(WorkItem::Eof);
175    }
176    if opts.logical {
177        for index in 0..doc.page_count() {
178            if let Some(filter) = &opts.pages {
179                if !filter.contains(&index) {
180                    continue;
181                }
182            }
183            work.push_back(WorkItem::Page(index));
184            work.push_back(WorkItem::PageResources(index));
185            if opts.content_ops {
186                work.push_back(WorkItem::PageContentOps(index));
187            }
188        }
189    }
190    work
191}
192
193/// Executes one work item, pushing its element(s) — or a salvage `Err` —
194/// into the pending queue.
195async fn produce(state: &mut StreamState, work: WorkItem) {
196    let doc = state.doc.clone();
197    match work {
198        WorkItem::Header => {
199            if let Some(span) = doc.header_span() {
200                state.pending.push_back(Ok(Element::Header {
201                    version: doc.version(),
202                    span,
203                }));
204            }
205        }
206        WorkItem::InFile { r, offset } => match doc.physical_object(r, offset).await {
207            Ok((span, object)) => state.pending.push_back(Ok(Element::IndirectObject {
208                r,
209                object,
210                span,
211                in_objstm: None,
212            })),
213            Err(err) => state.pending.push_back(Err(err)),
214        },
215        WorkItem::InStream {
216            r,
217            container,
218            index,
219        } => match doc.objstm_cache(container).await {
220            Ok(cache) => {
221                let member = cache.member_span(index).and_then(|member_span| {
222                    cache.object(index).map(|object| (member_span, object))
223                });
224                match member {
225                    Ok((member_span, object)) => {
226                        state.pending.push_back(Ok(Element::IndirectObject {
227                            r,
228                            object,
229                            span: cache.container_span,
230                            in_objstm: Some((cache.container, member_span)),
231                        }))
232                    }
233                    Err(err) => state.pending.push_back(Err(err)),
234                }
235            }
236            Err(err) => state.pending.push_back(Err(err)),
237        },
238        WorkItem::Section(index) => {
239            let record = &doc.sections()[index];
240            state.pending.push_back(Ok(Element::XrefSection {
241                kind: record.kind,
242                span: record.span,
243                entries: record.entries,
244            }));
245        }
246        WorkItem::Trailer => {
247            let (dict, span) = doc.merged_trailer();
248            state.pending.push_back(Ok(Element::Trailer { dict, span }));
249        }
250        WorkItem::StartXref => {
251            let (offset, span) = doc.startxref_record();
252            state
253                .pending
254                .push_back(Ok(Element::StartXref { offset, span }));
255        }
256        WorkItem::Eof => {
257            if let Some(span) = doc.eof_span() {
258                state.pending.push_back(Ok(Element::Eof { span }));
259            }
260        }
261        WorkItem::Page(index) => {
262            if let Some(record) = doc.page_record(index) {
263                if let Some(r) = record.r {
264                    state.pending.push_back(Ok(Element::Page { index, r }));
265                }
266            }
267        }
268        WorkItem::PageResources(index) => logical_resources(state, index).await,
269        WorkItem::PageContentOps(index) => content_ops(state, index).await,
270    }
271}
272
273/// Produces a page's fonts, images and annotations (in that order; fonts
274/// and images sorted by resource key name, annotations in `/Annots`
275/// order — adopted rule 7). Only entries that are indirect references
276/// yield elements; a font or annotation missing `/Subtype` still yields
277/// its element with an empty name (lenient, pinned by the core iterator).
278/// A resolve failure here can only be [`pdfboss_core::Error::CircularReference`]
279/// (a missing or unreadable target instead resolves leniently to `Null`);
280/// core's sync counterpart (`referenced_dict_entries`, the annotation loop)
281/// silently skips such an entry rather than surfacing it, so this mirrors
282/// that exactly — no salvage `Err` is pushed for it (CORE-PARITY).
283async fn logical_resources(state: &mut StreamState, page: usize) {
284    let doc = state.doc.clone();
285    let Some(record) = doc.page_record(page) else {
286        return;
287    };
288    let font_dict = resolved_category_dict(&doc, record.resources.get("Font")).await;
289    for value in sorted_dict_values(font_dict.as_ref()) {
290        let Some(r) = value.as_ref() else { continue };
291        let Ok(resolved) = doc.resolve(&value).await else {
292            continue; // CircularReference: skip, matching core exactly
293        };
294        let Some(dict) = resolved.as_dict() else {
295            continue;
296        };
297        let subtype = dict
298            .get_name("Subtype")
299            .cloned()
300            .unwrap_or_else(|| Name(String::new()));
301        let base_font = dict.get_name("BaseFont").cloned();
302        state.pending.push_back(Ok(Element::Font {
303            page: Some(page),
304            r,
305            subtype,
306            base_font,
307        }));
308    }
309    let xobject_dict = resolved_category_dict(&doc, record.resources.get("XObject")).await;
310    for value in sorted_dict_values(xobject_dict.as_ref()) {
311        let Some(r) = value.as_ref() else { continue };
312        let Ok(resolved) = doc.resolve(&value).await else {
313            continue; // CircularReference: skip, matching core exactly
314        };
315        let Some(dict) = resolved.as_dict() else {
316            continue;
317        };
318        if dict.get_name("Subtype").map(|n| n.0.as_str()) != Some("Image") {
319            continue; // form XObjects are not image elements
320        }
321        let width = dict_u32(dict, "Width");
322        let height = dict_u32(dict, "Height");
323        state.pending.push_back(Ok(Element::Image {
324            page: Some(page),
325            r,
326            width,
327            height,
328        }));
329    }
330    let annotations = match record.dict.get("Annots") {
331        Some(value) => match doc.resolve(value).await {
332            Ok(Object::Array(items)) => items,
333            // Both a non-array result and a resolve failure
334            // (CircularReference) yield no annotations, matching core's
335            // `if let Ok(Object::Array(items)) = self.doc.resolve(annots)`.
336            _ => Vec::new(),
337        },
338        None => Vec::new(),
339    };
340    for item in annotations {
341        let Some(r) = item.as_ref() else { continue };
342        let Ok(resolved) = doc.resolve(&item).await else {
343            continue; // CircularReference: skip, matching core exactly
344        };
345        let Some(dict) = resolved.as_dict() else {
346            continue;
347        };
348        let subtype = dict
349            .get_name("Subtype")
350            .cloned()
351            .unwrap_or_else(|| Name(String::new()));
352        state
353            .pending
354            .push_back(Ok(Element::Annotation { page, r, subtype }));
355    }
356}
357
358/// Resolves a resource-category value (e.g. the `/Font` entry of
359/// `/Resources`) to its dictionary. The category itself may be an
360/// indirect reference (legal PDF, e.g. `/Font 9 0 R`), so it must be
361/// resolved before being read as a dict — mirroring core's
362/// `referenced_dict_entries`. Lenient: a missing category, a resolve
363/// failure, or a non-dict result all yield `None` (no elements, no
364/// salvage `Err`), exactly as core's sync counterpart drops them.
365async fn resolved_category_dict(doc: &AsyncDocument, value: Option<&Object>) -> Option<Dict> {
366    let value = value?;
367    doc.resolve(value).await.ok()?.as_dict().cloned()
368}
369
370/// Values of an optional dictionary, sorted by key name (deterministic
371/// logical ordering — adopted rule 7).
372fn sorted_dict_values(dict: Option<&Dict>) -> Vec<Object> {
373    let Some(dict) = dict else {
374        return Vec::new();
375    };
376    let mut entries: Vec<(String, Object)> = dict
377        .iter()
378        .map(|(key, value)| (key.0.clone(), value.clone()))
379        .collect();
380    entries.sort_by(|a, b| a.0.cmp(&b.0));
381    entries.into_iter().map(|entry| entry.1).collect()
382}
383
384/// `dict[key]` as a `u32`, defaulting to 0 when missing or not a direct
385/// integer (adopted rule 7). Deliberately does not resolve indirect
386/// references: core's committed `page_elements` reads `Width`/`Height`
387/// with a plain `Dict::get_int` (no resolve), so an indirect value there
388/// is treated as invalid and defaults to 0 — this mirrors that exactly
389/// (CORE-PARITY).
390fn dict_u32(dict: &Dict, key: &str) -> u32 {
391    dict.get_int(key)
392        .and_then(|v| u32::try_from(v).ok())
393        .unwrap_or(0)
394}
395
396/// Produces a page's content operators with their byte ranges within the
397/// decoded, concatenated content stream (adopted rule 8). Parsing itself
398/// is delegated to core's own `parse_content_spanned` — the exact function
399/// core's `page_elements` calls — so op/span boundaries (including inline
400/// images and unknown-operator drops) can never diverge from core.
401async fn content_ops(state: &mut StreamState, page: usize) {
402    let doc = state.doc.clone();
403    let Ok(core_page) = doc.page(page) else {
404        return;
405    };
406    // The shared implementation both APIs use; the duplicate that used to
407    // live in this file is gone, so the two cannot drift.
408    let decoded = match pdfboss_core::page_content_with(&doc, &core_page).await {
409        Ok(decoded) => decoded,
410        Err(err) => {
411            state.pending.push_back(Err(Error::Core(err)));
412            return;
413        }
414    };
415    match pdfboss_core::content::parse_content_spanned(&decoded) {
416        Ok(spanned) => {
417            for (op, span) in spanned {
418                state.pending.push_back(Ok(Element::ContentOp {
419                    page,
420                    op,
421                    span_in_content: span,
422                }));
423            }
424        }
425        Err(err) => state.pending.push_back(Err(Error::Core(err))),
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use futures_util::StreamExt;
432    use pdfboss_core::elements::{Element, ElementOpts, Span, XrefKind};
433    use pdfboss_core::ObjRef;
434    use pdfboss_testkit::{multi_page_doc, simple_doc, PdfBuilder};
435
436    use crate::document::AsyncDocument;
437    use crate::error::Result;
438
439    fn physical_opts() -> ElementOpts {
440        ElementOpts {
441            physical: true,
442            logical: false,
443            pages: None,
444            content_ops: false,
445        }
446    }
447
448    async fn collect(doc: &AsyncDocument, opts: ElementOpts) -> Vec<Result<Element>> {
449        let mut stream = doc.elements(opts);
450        let mut items = Vec::new();
451        while let Some(item) = stream.next().await {
452            items.push(item);
453        }
454        items
455    }
456
457    #[tokio::test]
458    async fn physical_sequence_shape_for_a_classic_document() {
459        let data = simple_doc("elements");
460        let file_len = data.len() as u64;
461        // The fixture's `%%EOF` is followed by a trailing newline, so the
462        // marker's own span (mirroring core's `eof_element`) ends short of
463        // `file_len` by exactly that byte.
464        let eof_pos = data
465            .windows(b"%%EOF".len())
466            .position(|w| w == b"%%EOF")
467            .unwrap() as u64;
468        let doc = AsyncDocument::from_bytes(data).await.unwrap();
469        let elements: Vec<Element> = collect(&doc, physical_opts())
470            .await
471            .into_iter()
472            .map(|item| item.unwrap())
473            .collect();
474        // `%PDF-1.7` at offset 0: the header span covers the version run.
475        assert!(matches!(
476            elements[0],
477            Element::Header { version: (1, 7), span } if span.start == 0 && span.end == 8
478        ));
479        let object_numbers: Vec<u32> = elements
480            .iter()
481            .filter_map(|el| match el {
482                Element::IndirectObject { r, .. } => Some(r.num),
483                _ => None,
484            })
485            .collect();
486        assert_eq!(
487            object_numbers,
488            vec![1, 2, 3, 4, 5],
489            "objects in offset order"
490        );
491        let mut previous_end = 0;
492        for element in &elements {
493            if let Element::IndirectObject { span, .. } = element {
494                assert!(span.start >= previous_end, "object spans are disjoint");
495                assert!(span.end <= file_len, "spans stay in bounds");
496                previous_end = span.end;
497            }
498        }
499        // Tail shape (adopted rule 4): xref, trailer, startxref, eof.
500        assert!(matches!(
501            elements[elements.len() - 4],
502            Element::XrefSection {
503                kind: XrefKind::Table,
504                entries: 6,
505                ..
506            }
507        ));
508        assert!(matches!(
509            &elements[elements.len() - 3],
510            Element::Trailer { dict, .. } if dict.get("Root").is_some()
511        ));
512        assert!(matches!(
513            elements[elements.len() - 2],
514            Element::StartXref { .. }
515        ));
516        assert!(matches!(
517            elements[elements.len() - 1],
518            Element::Eof { span } if span.start == eof_pos && span.end == eof_pos + 5
519        ));
520    }
521
522    #[tokio::test]
523    async fn objstm_members_follow_their_container() {
524        let (dict, payload) = pdfboss_testkit::objstm_payload(&[
525            (1, "<< /Type /Catalog /Pages 2 0 R >>"),
526            (5, "(member)"),
527        ]);
528        let mut b = pdfboss_testkit::PdfBuilder::new();
529        b.stream(6, &dict, &payload);
530        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
531        let doc = AsyncDocument::from_bytes(b.build_xref_stream(1))
532            .await
533            .unwrap();
534        let elements: Vec<Element> = collect(&doc, physical_opts())
535            .await
536            .into_iter()
537            .map(|item| item.unwrap())
538            .collect();
539        let object_sequence: Vec<(u32, bool)> = elements
540            .iter()
541            .filter_map(|el| match el {
542                Element::IndirectObject { r, in_objstm, .. } => Some((r.num, in_objstm.is_some())),
543                _ => None,
544            })
545            .collect();
546        let container_pos = object_sequence
547            .iter()
548            .position(|&(num, is_member)| num == 6 && !is_member)
549            .expect("container element present");
550        assert_eq!(object_sequence[container_pos + 1], (1, true));
551        assert_eq!(object_sequence[container_pos + 2], (5, true));
552        let member = elements
553            .iter()
554            .find_map(|el| match el {
555                Element::IndirectObject {
556                    r,
557                    in_objstm: Some((container, member_span)),
558                    ..
559                } if r.num == 5 => Some((*container, *member_span)),
560                _ => None,
561            })
562            .expect("member element present");
563        assert_eq!(member.0, ObjRef { num: 6, gen: 0 });
564        assert!(member.1.start < member.1.end);
565    }
566
567    #[tokio::test]
568    async fn broken_objects_yield_err_and_the_stream_continues() {
569        // Corrupt one object header without moving offsets: object 5's
570        // header keyword becomes garbage of equal length.
571        let mut data = simple_doc("salvage");
572        let pos = data
573            .windows(b"5 0 obj".len())
574            .position(|w| w == b"5 0 obj")
575            .unwrap();
576        data[pos..pos + 7].copy_from_slice(b"5 0 ob!");
577        let doc = AsyncDocument::from_bytes(data).await.unwrap();
578        let items = collect(&doc, physical_opts()).await;
579        assert!(
580            items.iter().any(|item| item.is_err()),
581            "the bad object surfaces as Err"
582        );
583        let good: Vec<u32> = items
584            .iter()
585            .filter_map(|item| match item {
586                Ok(Element::IndirectObject { r, .. }) => Some(r.num),
587                _ => None,
588            })
589            .collect();
590        assert_eq!(good, vec![1, 2, 3, 4], "all other objects still stream");
591        assert!(
592            items
593                .iter()
594                .any(|item| matches!(item, Ok(Element::Eof { .. }))),
595            "the stream runs to the end"
596        );
597    }
598
599    #[tokio::test]
600    async fn element_stream_is_send() {
601        fn assert_send<T: Send>(value: T) -> T {
602            value
603        }
604        // Proves `ElementStream` carries no borrow of the `AsyncDocument`
605        // that created it (Plan 03/PyO3 needs `'static + Send` streams).
606        fn requires_static<T: Send + 'static>(value: T) -> T {
607            value
608        }
609        let doc = AsyncDocument::from_bytes(simple_doc("send")).await.unwrap();
610        let mut stream = requires_static(assert_send(doc.elements(physical_opts())));
611        drop(doc); // the stream must not depend on `doc` staying alive
612        assert!(stream.next().await.is_some());
613    }
614
615    #[tokio::test]
616    async fn logical_layer_lists_pages_fonts_images_annotations() {
617        let mut b = PdfBuilder::new();
618        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
619        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
620        b.object(
621            3,
622            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
623             /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 7 0 R >> >> \
624             /Contents 4 0 R /Annots [8 0 R] >>",
625        );
626        b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (pic) Tj ET");
627        b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
628        b.stream(
629            7,
630            "/Type /XObject /Subtype /Image /Width 2 /Height 3 \
631             /ColorSpace /DeviceGray /BitsPerComponent 8",
632            &[0u8; 6],
633        );
634        b.object(8, "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>");
635        let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
636        let opts = ElementOpts {
637            physical: false,
638            logical: true,
639            pages: None,
640            content_ops: false,
641        };
642        let elements: Vec<Element> = collect(&doc, opts)
643            .await
644            .into_iter()
645            .map(|item| item.unwrap())
646            .collect();
647        assert!(matches!(
648            elements[0],
649            Element::Page { index: 0, r } if r.num == 3
650        ));
651        assert!(matches!(
652            &elements[1],
653            Element::Font { page: Some(0), r, subtype, base_font: Some(base) }
654                if r.num == 5 && subtype.0 == "Type1" && base.0 == "Helvetica"
655        ));
656        assert!(matches!(
657            &elements[2],
658            Element::Image { page: Some(0), r, width: 2, height: 3 } if r.num == 7
659        ));
660        assert!(matches!(
661            &elements[3],
662            Element::Annotation { page: 0, r, subtype }
663                if r.num == 8 && subtype.0 == "Link"
664        ));
665        assert_eq!(elements.len(), 4);
666    }
667
668    #[tokio::test]
669    async fn content_ops_spans_reslice_to_the_same_op() {
670        let doc = AsyncDocument::from_bytes(simple_doc("ops")).await.unwrap();
671        let opts = ElementOpts {
672            physical: false,
673            logical: true,
674            pages: None,
675            content_ops: true,
676        };
677        let items = collect(&doc, opts).await;
678        // Recompute the decoded content the same way the sync page API does.
679        let sync_doc = pdfboss_core::Document::load(simple_doc("ops")).unwrap();
680        let decoded = sync_doc.page(0).unwrap().content(&sync_doc).unwrap();
681        let ops: Vec<(pdfboss_core::content::Op, Span)> = items
682            .iter()
683            .filter_map(|item| match item {
684                Ok(Element::ContentOp {
685                    op,
686                    span_in_content,
687                    ..
688                }) => Some((op.clone(), *span_in_content)),
689                _ => None,
690            })
691            .collect();
692        assert!(!ops.is_empty());
693        // The streamed op list matches a straight parse of the content.
694        let expected = pdfboss_core::content::parse_content(&decoded).unwrap();
695        let streamed: Vec<pdfboss_core::content::Op> =
696            ops.iter().map(|entry| entry.0.clone()).collect();
697        assert_eq!(streamed, expected);
698        // Re-lexing each span yields exactly that op again.
699        for (op, span) in &ops {
700            let slice = &decoded[span.start as usize..span.end as usize];
701            let reparsed = pdfboss_core::content::parse_content(slice).unwrap();
702            assert_eq!(reparsed.len(), 1, "span {span:?} holds one op");
703            assert_eq!(&reparsed[0], op);
704        }
705    }
706
707    #[tokio::test]
708    async fn pages_filter_restricts_the_logical_layer() {
709        let doc = AsyncDocument::from_bytes(multi_page_doc(&["a", "b", "c"]))
710            .await
711            .unwrap();
712        let opts = ElementOpts {
713            physical: false,
714            logical: true,
715            pages: Some(vec![1]),
716            content_ops: false,
717        };
718        let elements: Vec<Element> = collect(&doc, opts)
719            .await
720            .into_iter()
721            .map(|item| item.unwrap())
722            .collect();
723        let page_indices: Vec<usize> = elements
724            .iter()
725            .filter_map(|el| match el {
726                Element::Page { index, .. } => Some(*index),
727                _ => None,
728            })
729            .collect();
730        assert_eq!(page_indices, vec![1]);
731        assert!(elements.iter().all(|el| match el {
732            Element::Font { page, .. } => *page == Some(1),
733            _ => true,
734        }));
735    }
736
737    #[tokio::test]
738    async fn page_records_follow_document_order() {
739        let opts = ElementOpts {
740            physical: false,
741            logical: true,
742            pages: None,
743            content_ops: false,
744        };
745        let doc = AsyncDocument::from_bytes(multi_page_doc(&["a", "b", "c"]))
746            .await
747            .unwrap();
748        let elements: Vec<Element> = collect(&doc, opts.clone())
749            .await
750            .into_iter()
751            .map(|item| item.unwrap())
752            .collect();
753        let async_pages: Vec<(usize, ObjRef)> = elements
754            .iter()
755            .filter_map(|el| match el {
756                Element::Page { index, r } => Some((*index, *r)),
757                _ => None,
758            })
759            .collect();
760
761        // Independently re-derive the same order via the sync core walk —
762        // the parity arbiter for logical ordering.
763        let sync_doc = pdfboss_core::Document::load(multi_page_doc(&["a", "b", "c"])).unwrap();
764        let sync_pages: Vec<(usize, ObjRef)> = sync_doc
765            .elements(opts)
766            .collect::<pdfboss_core::Result<Vec<_>>>()
767            .unwrap()
768            .into_iter()
769            .filter_map(|el| match el {
770                Element::Page { index, r } => Some((index, r)),
771                _ => None,
772            })
773            .collect();
774
775        assert_eq!(
776            async_pages, sync_pages,
777            "page order and object refs match the sync core walk exactly"
778        );
779        assert_eq!(
780            async_pages,
781            vec![
782                (0, ObjRef { num: 4, gen: 0 }),
783                (1, ObjRef { num: 6, gen: 0 }),
784                (2, ObjRef { num: 8, gen: 0 }),
785            ]
786        );
787    }
788
789    #[tokio::test]
790    async fn inline_page_kid_yields_no_page_element() {
791        // A page tree whose /Kids holds an inline (non-Ref) page dict as
792        // its first child and a normal indirect reference as its second.
793        let mut b = PdfBuilder::new();
794        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
795        b.object(
796            2,
797            "<< /Type /Pages /Kids [<< /Type /Page /Parent 2 0 R \
798             /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> >> \
799             3 0 R] /Count 2 >>",
800        );
801        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>");
802        b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
803        let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
804        assert_eq!(doc.page_count(), 2, "both children count as pages");
805        let opts = ElementOpts {
806            physical: false,
807            logical: true,
808            pages: None,
809            content_ops: false,
810        };
811        let elements: Vec<Element> = collect(&doc, opts)
812            .await
813            .into_iter()
814            .map(|item| item.unwrap())
815            .collect();
816        let page_indices: Vec<usize> = elements
817            .iter()
818            .filter_map(|el| match el {
819                Element::Page { index, .. } => Some(*index),
820                _ => None,
821            })
822            .collect();
823        assert_eq!(
824            page_indices,
825            vec![1],
826            "the inline kid (index 0) yields no Page element; the Ref kid (index 1) still does"
827        );
828        let font_pages: Vec<Option<usize>> = elements
829            .iter()
830            .filter_map(|el| match el {
831                Element::Font { page, .. } => Some(*page),
832                _ => None,
833            })
834            .collect();
835        assert!(
836            font_pages.contains(&Some(0)),
837            "the inline page's own resources still yield child elements"
838        );
839    }
840
841    #[tokio::test]
842    async fn indirect_width_or_height_defaults_to_zero_matching_core() {
843        // Core's committed `page_elements` reads Width/Height with a plain
844        // `Dict::get_int` (no resolve): an indirect value there is
845        // "invalid" and defaults to 0. This must hold here too, even
846        // though the async layer resolves everything else through the
847        // same `resolve()` path.
848        let mut b = PdfBuilder::new();
849        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
850        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
851        b.object(
852            3,
853            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
854             /Resources << /XObject << /Im0 7 0 R >> >> >>",
855        );
856        b.object(9, "2"); // the indirect integer /Width points at
857        b.stream(
858            7,
859            "/Type /XObject /Subtype /Image /Width 9 0 R /Height 3 \
860             /ColorSpace /DeviceGray /BitsPerComponent 8",
861            &[0u8; 6],
862        );
863        let bytes = b.build(1);
864        let doc = AsyncDocument::from_bytes(bytes.clone()).await.unwrap();
865        let opts = ElementOpts {
866            physical: false,
867            logical: true,
868            pages: None,
869            content_ops: false,
870        };
871        let elements: Vec<Element> = collect(&doc, opts.clone())
872            .await
873            .into_iter()
874            .map(|item| item.unwrap())
875            .collect();
876        let image = elements
877            .iter()
878            .find_map(|el| match el {
879                Element::Image { width, height, .. } => Some((*width, *height)),
880                _ => None,
881            })
882            .expect("image element present");
883        assert_eq!(
884            image,
885            (0, 3),
886            "an indirect /Width is not resolved: it defaults to 0, matching core"
887        );
888
889        // Cross-check against the sync core walk directly.
890        let sync_doc = pdfboss_core::Document::load(bytes).unwrap();
891        let sync_image = sync_doc
892            .elements(opts)
893            .collect::<pdfboss_core::Result<Vec<_>>>()
894            .unwrap()
895            .into_iter()
896            .find_map(|el| match el {
897                Element::Image { width, height, .. } => Some((width, height)),
898                _ => None,
899            })
900            .expect("sync image element present");
901        assert_eq!(image, sync_image, "matches the sync core walk exactly");
902    }
903
904    #[tokio::test]
905    async fn content_ops_match_core_across_varied_operators() {
906        // End-to-end check of the async content-ops path (page-content
907        // decode/concatenation, then core's own `parse_content_spanned`)
908        // against the sync core walk on the same bytes: a TJ array
909        // operand, an unrecognized operator (dropped, no arity match), and
910        // an inline image, alongside plain operators.
911        let content = "q 1 0 0 1 10 20 cm 0 0 10 10 re f Q \
912                        BT /F1 12 Tf [(Hi) -250 (there)] TJ ET \
913                        zzUnknownOp 1 2 \
914                        BI /W 1 /H 1 /BPC 8 /CS /G ID \x01 EI";
915        let bytes = pdfboss_testkit::doc_with_graphics(content);
916        let doc = AsyncDocument::from_bytes(bytes.clone()).await.unwrap();
917        let opts = ElementOpts {
918            physical: false,
919            logical: true,
920            pages: None,
921            content_ops: true,
922        };
923        let items = collect(&doc, opts).await;
924        assert!(
925            items.iter().all(|item| item.is_ok()),
926            "no salvage errors expected on well-formed content: {items:?}"
927        );
928        let streamed: Vec<(pdfboss_core::content::Op, Span)> = items
929            .iter()
930            .filter_map(|item| match item {
931                Ok(Element::ContentOp {
932                    op,
933                    span_in_content,
934                    ..
935                }) => Some((op.clone(), *span_in_content)),
936                _ => None,
937            })
938            .collect();
939
940        let sync_doc = pdfboss_core::Document::load(bytes).unwrap();
941        let decoded = sync_doc.page(0).unwrap().content(&sync_doc).unwrap();
942        let expected = pdfboss_core::content::parse_content_spanned(&decoded).unwrap();
943        assert_eq!(
944            streamed, expected,
945            "op sequence and spans match core's own spanned parse exactly"
946        );
947    }
948
949    #[tokio::test]
950    async fn indirect_resource_category_still_enumerates() {
951        // /Resources /Font is itself an indirect reference to the font
952        // category dict (legal PDF) rather than an inline dict — the
953        // category value must be resolved before its entries are read.
954        let mut b = PdfBuilder::new();
955        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
956        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
957        b.object(
958            3,
959            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
960             /Resources << /Font 6 0 R >> >>",
961        );
962        b.object(6, "<< /F1 5 0 R >>");
963        b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
964        let bytes = b.build(1);
965        let doc = AsyncDocument::from_bytes(bytes.clone()).await.unwrap();
966        let opts = ElementOpts {
967            physical: false,
968            logical: true,
969            pages: None,
970            content_ops: false,
971        };
972        let elements: Vec<Element> = collect(&doc, opts.clone())
973            .await
974            .into_iter()
975            .map(|item| item.unwrap())
976            .collect();
977        let fonts: Vec<(ObjRef, String, Option<String>)> = elements
978            .iter()
979            .filter_map(|el| match el {
980                Element::Font {
981                    r,
982                    subtype,
983                    base_font,
984                    ..
985                } => Some((
986                    *r,
987                    subtype.0.clone(),
988                    base_font.as_ref().map(|n| n.0.clone()),
989                )),
990                _ => None,
991            })
992            .collect();
993        assert_eq!(
994            fonts,
995            vec![(
996                ObjRef { num: 5, gen: 0 },
997                "Type1".to_string(),
998                Some("Helvetica".to_string())
999            )],
1000            "an indirect /Font category dict is still resolved and enumerated"
1001        );
1002
1003        // Cross-check parity against the sync core walk on the same bytes.
1004        let sync_doc = pdfboss_core::Document::load(bytes).unwrap();
1005        let sync_fonts: Vec<(ObjRef, String, Option<String>)> = sync_doc
1006            .elements(opts)
1007            .collect::<pdfboss_core::Result<Vec<_>>>()
1008            .unwrap()
1009            .into_iter()
1010            .filter_map(|el| match el {
1011                Element::Font {
1012                    r,
1013                    subtype,
1014                    base_font,
1015                    ..
1016                } => Some((
1017                    r,
1018                    subtype.0.clone(),
1019                    base_font.as_ref().map(|n| n.0.clone()),
1020                )),
1021                _ => None,
1022            })
1023            .collect();
1024        assert_eq!(fonts, sync_fonts, "matches the sync core walk exactly");
1025    }
1026}