1use 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
21pub 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
37enum 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
59struct StreamState {
63 doc: AsyncDocument,
64 work: VecDeque<WorkItem>,
65 pending: VecDeque<Result<Element>>,
66}
67
68pub(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
93struct OrderEntry {
97 num: u32,
98 entry: XrefEntry,
99 sort_offset: u64,
101 sort_member: u64,
106}
107
108fn 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 _ => 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 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
193async 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
273async 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; };
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; };
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; }
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 _ => 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; };
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
358async 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
370fn 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
384fn 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
396async 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 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 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 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 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 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 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); 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 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 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 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 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 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 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"); 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 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 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 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 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}