use std::collections::{HashMap, VecDeque};
use std::pin::Pin;
use std::task::{Context, Poll};
use futures_core::stream::BoxStream;
use pdfboss_core::elements::{Element, ElementOpts};
use pdfboss_core::xref::XrefEntry;
use pdfboss_core::{Dict, Name, ObjRef, Object};
use crate::document::{AsyncDocument, PageRecord};
use crate::error::{Error, Result};
pub struct ElementStream {
inner: BoxStream<'static, Result<Element>>,
}
impl futures_core::Stream for ElementStream {
type Item = Result<Element>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.get_mut().inner.as_mut().poll_next(cx)
}
}
enum WorkItem {
Header,
InFile {
r: ObjRef,
offset: u64,
},
InStream {
r: ObjRef,
container: u32,
index: u32,
},
Section(usize),
Trailer,
StartXref,
Eof,
Page(usize),
PageResources(usize),
PageContentOps(usize),
}
struct StreamState {
doc: AsyncDocument,
work: VecDeque<WorkItem>,
pending: VecDeque<Result<Element>>,
}
pub(crate) fn element_stream(doc: &AsyncDocument, opts: ElementOpts) -> ElementStream {
let state = StreamState {
doc: doc.clone(),
work: build_worklist(doc, &opts),
pending: VecDeque::new(),
};
ElementStream {
inner: Box::pin(futures_util::stream::unfold(
state,
|mut state| async move {
loop {
if let Some(item) = state.pending.pop_front() {
return Some((item, state));
}
let work = state.work.pop_front()?;
produce(&mut state, work).await;
}
},
)),
}
}
struct OrderEntry {
num: u32,
entry: XrefEntry,
sort_offset: u64,
sort_member: u64,
}
fn build_worklist(doc: &AsyncDocument, opts: &ElementOpts) -> VecDeque<WorkItem> {
let mut work = VecDeque::new();
if opts.physical {
work.push_back(WorkItem::Header);
let entries = doc.xref_entries();
let by_num: HashMap<u32, XrefEntry> = entries.iter().copied().collect();
let mut order: Vec<OrderEntry> = entries
.into_iter()
.filter_map(|(num, entry)| match entry {
XrefEntry::Free => None,
XrefEntry::InFile { offset, .. } => Some(OrderEntry {
num,
entry,
sort_offset: offset,
sort_member: 0,
}),
XrefEntry::InStream { stream_num, index } => {
let sort_offset = match by_num.get(&stream_num) {
Some(XrefEntry::InFile { offset, .. }) => *offset,
_ => u64::MAX,
};
Some(OrderEntry {
num,
entry,
sort_offset,
sort_member: 1 + u64::from(index),
})
}
})
.collect();
order.sort_by_key(|e| (e.sort_offset, e.sort_member, e.num));
for e in order {
match e.entry {
XrefEntry::InFile { offset, gen } => {
work.push_back(WorkItem::InFile {
r: ObjRef { num: e.num, gen },
offset,
});
}
XrefEntry::InStream { stream_num, index } => {
work.push_back(WorkItem::InStream {
r: ObjRef { num: e.num, gen: 0 },
container: stream_num,
index,
});
}
XrefEntry::Free => unreachable!("filtered out above"),
}
}
for section_index in 0..doc.sections().len() {
work.push_back(WorkItem::Section(section_index));
}
work.push_back(WorkItem::Trailer);
work.push_back(WorkItem::StartXref);
work.push_back(WorkItem::Eof);
}
if opts.logical {
for index in 0..doc.page_count() {
if let Some(filter) = &opts.pages {
if !filter.contains(&index) {
continue;
}
}
work.push_back(WorkItem::Page(index));
work.push_back(WorkItem::PageResources(index));
if opts.content_ops {
work.push_back(WorkItem::PageContentOps(index));
}
}
}
work
}
async fn produce(state: &mut StreamState, work: WorkItem) {
let doc = state.doc.clone();
match work {
WorkItem::Header => {
if let Some(span) = doc.header_span() {
state.pending.push_back(Ok(Element::Header {
version: doc.version(),
span,
}));
}
}
WorkItem::InFile { r, offset } => match doc.physical_object(r, offset).await {
Ok((span, object)) => state.pending.push_back(Ok(Element::IndirectObject {
r,
object,
span,
in_objstm: None,
})),
Err(err) => state.pending.push_back(Err(err)),
},
WorkItem::InStream {
r,
container,
index,
} => match doc.objstm_cache(container).await {
Ok(cache) => {
let member = cache.member_span(index).and_then(|member_span| {
cache.object(index).map(|object| (member_span, object))
});
match member {
Ok((member_span, object)) => {
state.pending.push_back(Ok(Element::IndirectObject {
r,
object,
span: cache.container_span,
in_objstm: Some((cache.container, member_span)),
}))
}
Err(err) => state.pending.push_back(Err(err)),
}
}
Err(err) => state.pending.push_back(Err(err)),
},
WorkItem::Section(index) => {
let record = &doc.sections()[index];
state.pending.push_back(Ok(Element::XrefSection {
kind: record.kind,
span: record.span,
entries: record.entries,
}));
}
WorkItem::Trailer => {
let (dict, span) = doc.merged_trailer();
state.pending.push_back(Ok(Element::Trailer { dict, span }));
}
WorkItem::StartXref => {
let (offset, span) = doc.startxref_record();
state
.pending
.push_back(Ok(Element::StartXref { offset, span }));
}
WorkItem::Eof => {
if let Some(span) = doc.eof_span() {
state.pending.push_back(Ok(Element::Eof { span }));
}
}
WorkItem::Page(index) => {
if let Some(record) = doc.page_record(index) {
if let Some(r) = record.r {
state.pending.push_back(Ok(Element::Page { index, r }));
}
}
}
WorkItem::PageResources(index) => logical_resources(state, index).await,
WorkItem::PageContentOps(index) => content_ops(state, index).await,
}
}
async fn logical_resources(state: &mut StreamState, page: usize) {
let doc = state.doc.clone();
let Some(record) = doc.page_record(page) else {
return;
};
let font_dict = resolved_category_dict(&doc, record.resources.get("Font")).await;
for value in sorted_dict_values(font_dict.as_ref()) {
let Some(r) = value.as_ref() else { continue };
let Ok(resolved) = doc.resolve(&value).await else {
continue; };
let Some(dict) = resolved.as_dict() else {
continue;
};
let subtype = dict
.get_name("Subtype")
.cloned()
.unwrap_or_else(|| Name(String::new()));
let base_font = dict.get_name("BaseFont").cloned();
state.pending.push_back(Ok(Element::Font {
page: Some(page),
r,
subtype,
base_font,
}));
}
let xobject_dict = resolved_category_dict(&doc, record.resources.get("XObject")).await;
for value in sorted_dict_values(xobject_dict.as_ref()) {
let Some(r) = value.as_ref() else { continue };
let Ok(resolved) = doc.resolve(&value).await else {
continue; };
let Some(dict) = resolved.as_dict() else {
continue;
};
if dict.get_name("Subtype").map(|n| n.0.as_str()) != Some("Image") {
continue; }
let width = dict_u32(dict, "Width");
let height = dict_u32(dict, "Height");
state.pending.push_back(Ok(Element::Image {
page: Some(page),
r,
width,
height,
}));
}
let annotations = match record.dict.get("Annots") {
Some(value) => match doc.resolve(value).await {
Ok(Object::Array(items)) => items,
_ => Vec::new(),
},
None => Vec::new(),
};
for item in annotations {
let Some(r) = item.as_ref() else { continue };
let Ok(resolved) = doc.resolve(&item).await else {
continue; };
let Some(dict) = resolved.as_dict() else {
continue;
};
let subtype = dict
.get_name("Subtype")
.cloned()
.unwrap_or_else(|| Name(String::new()));
state
.pending
.push_back(Ok(Element::Annotation { page, r, subtype }));
}
}
async fn resolved_category_dict(doc: &AsyncDocument, value: Option<&Object>) -> Option<Dict> {
let value = value?;
doc.resolve(value).await.ok()?.as_dict().cloned()
}
fn sorted_dict_values(dict: Option<&Dict>) -> Vec<Object> {
let Some(dict) = dict else {
return Vec::new();
};
let mut entries: Vec<(String, Object)> = dict
.iter()
.map(|(key, value)| (key.0.clone(), value.clone()))
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries.into_iter().map(|entry| entry.1).collect()
}
fn dict_u32(dict: &Dict, key: &str) -> u32 {
dict.get_int(key)
.and_then(|v| u32::try_from(v).ok())
.unwrap_or(0)
}
async fn content_ops(state: &mut StreamState, page: usize) {
let doc = state.doc.clone();
let Some(record) = doc.page_record(page) else {
return;
};
let decoded = match page_content(&doc, &record).await {
Ok(decoded) => decoded,
Err(err) => {
state.pending.push_back(Err(err));
return;
}
};
match pdfboss_core::content::parse_content_spanned(&decoded) {
Ok(spanned) => {
for (op, span) in spanned {
state.pending.push_back(Ok(Element::ContentOp {
page,
op,
span_in_content: span,
}));
}
}
Err(err) => state.pending.push_back(Err(Error::Core(err))),
}
}
async fn page_content(doc: &AsyncDocument, record: &PageRecord) -> Result<Vec<u8>> {
let Some(contents) = record.dict.get("Contents") else {
return Ok(Vec::new());
};
match doc.resolve(contents).await? {
Object::Stream(ref s) => doc.decode_stream(s).await,
Object::Array(items) => {
let mut out = Vec::new();
let mut first = true;
for item in &items {
let part = doc.resolve(item).await?;
let Some(stream) = part.as_stream() else {
continue; };
if !first {
out.push(b'\n');
}
out.extend_from_slice(&doc.decode_stream(stream).await?);
first = false;
}
Ok(out)
}
_ => Ok(Vec::new()),
}
}
#[cfg(test)]
mod tests {
use futures_util::StreamExt;
use pdfboss_core::elements::{Element, ElementOpts, Span, XrefKind};
use pdfboss_core::ObjRef;
use pdfboss_testkit::{multi_page_doc, simple_doc, PdfBuilder};
use crate::document::AsyncDocument;
use crate::error::Result;
fn physical_opts() -> ElementOpts {
ElementOpts {
physical: true,
logical: false,
pages: None,
content_ops: false,
}
}
async fn collect(doc: &AsyncDocument, opts: ElementOpts) -> Vec<Result<Element>> {
let mut stream = doc.elements(opts);
let mut items = Vec::new();
while let Some(item) = stream.next().await {
items.push(item);
}
items
}
#[tokio::test]
async fn physical_sequence_shape_for_a_classic_document() {
let data = simple_doc("elements");
let file_len = data.len() as u64;
let eof_pos = data
.windows(b"%%EOF".len())
.position(|w| w == b"%%EOF")
.unwrap() as u64;
let doc = AsyncDocument::from_bytes(data).await.unwrap();
let elements: Vec<Element> = collect(&doc, physical_opts())
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
assert!(matches!(
elements[0],
Element::Header { version: (1, 7), span } if span.start == 0 && span.end == 8
));
let object_numbers: Vec<u32> = elements
.iter()
.filter_map(|el| match el {
Element::IndirectObject { r, .. } => Some(r.num),
_ => None,
})
.collect();
assert_eq!(
object_numbers,
vec![1, 2, 3, 4, 5],
"objects in offset order"
);
let mut previous_end = 0;
for element in &elements {
if let Element::IndirectObject { span, .. } = element {
assert!(span.start >= previous_end, "object spans are disjoint");
assert!(span.end <= file_len, "spans stay in bounds");
previous_end = span.end;
}
}
assert!(matches!(
elements[elements.len() - 4],
Element::XrefSection {
kind: XrefKind::Table,
entries: 6,
..
}
));
assert!(matches!(
&elements[elements.len() - 3],
Element::Trailer { dict, .. } if dict.get("Root").is_some()
));
assert!(matches!(
elements[elements.len() - 2],
Element::StartXref { .. }
));
assert!(matches!(
elements[elements.len() - 1],
Element::Eof { span } if span.start == eof_pos && span.end == eof_pos + 5
));
}
#[tokio::test]
async fn objstm_members_follow_their_container() {
let (dict, payload) = pdfboss_testkit::objstm_payload(&[
(1, "<< /Type /Catalog /Pages 2 0 R >>"),
(5, "(member)"),
]);
let mut b = pdfboss_testkit::PdfBuilder::new();
b.stream(6, &dict, &payload);
b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
let doc = AsyncDocument::from_bytes(b.build_xref_stream(1))
.await
.unwrap();
let elements: Vec<Element> = collect(&doc, physical_opts())
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
let object_sequence: Vec<(u32, bool)> = elements
.iter()
.filter_map(|el| match el {
Element::IndirectObject { r, in_objstm, .. } => Some((r.num, in_objstm.is_some())),
_ => None,
})
.collect();
let container_pos = object_sequence
.iter()
.position(|&(num, is_member)| num == 6 && !is_member)
.expect("container element present");
assert_eq!(object_sequence[container_pos + 1], (1, true));
assert_eq!(object_sequence[container_pos + 2], (5, true));
let member = elements
.iter()
.find_map(|el| match el {
Element::IndirectObject {
r,
in_objstm: Some((container, member_span)),
..
} if r.num == 5 => Some((*container, *member_span)),
_ => None,
})
.expect("member element present");
assert_eq!(member.0, ObjRef { num: 6, gen: 0 });
assert!(member.1.start < member.1.end);
}
#[tokio::test]
async fn broken_objects_yield_err_and_the_stream_continues() {
let mut data = simple_doc("salvage");
let pos = data
.windows(b"5 0 obj".len())
.position(|w| w == b"5 0 obj")
.unwrap();
data[pos..pos + 7].copy_from_slice(b"5 0 ob!");
let doc = AsyncDocument::from_bytes(data).await.unwrap();
let items = collect(&doc, physical_opts()).await;
assert!(
items.iter().any(|item| item.is_err()),
"the bad object surfaces as Err"
);
let good: Vec<u32> = items
.iter()
.filter_map(|item| match item {
Ok(Element::IndirectObject { r, .. }) => Some(r.num),
_ => None,
})
.collect();
assert_eq!(good, vec![1, 2, 3, 4], "all other objects still stream");
assert!(
items
.iter()
.any(|item| matches!(item, Ok(Element::Eof { .. }))),
"the stream runs to the end"
);
}
#[tokio::test]
async fn element_stream_is_send() {
fn assert_send<T: Send>(value: T) -> T {
value
}
fn requires_static<T: Send + 'static>(value: T) -> T {
value
}
let doc = AsyncDocument::from_bytes(simple_doc("send")).await.unwrap();
let mut stream = requires_static(assert_send(doc.elements(physical_opts())));
drop(doc); assert!(stream.next().await.is_some());
}
#[tokio::test]
async fn logical_layer_lists_pages_fonts_images_annotations() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> /XObject << /Im0 7 0 R >> >> \
/Contents 4 0 R /Annots [8 0 R] >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (pic) Tj ET");
b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
b.stream(
7,
"/Type /XObject /Subtype /Image /Width 2 /Height 3 \
/ColorSpace /DeviceGray /BitsPerComponent 8",
&[0u8; 6],
);
b.object(8, "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>");
let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
let opts = ElementOpts {
physical: false,
logical: true,
pages: None,
content_ops: false,
};
let elements: Vec<Element> = collect(&doc, opts)
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
assert!(matches!(
elements[0],
Element::Page { index: 0, r } if r.num == 3
));
assert!(matches!(
&elements[1],
Element::Font { page: Some(0), r, subtype, base_font: Some(base) }
if r.num == 5 && subtype.0 == "Type1" && base.0 == "Helvetica"
));
assert!(matches!(
&elements[2],
Element::Image { page: Some(0), r, width: 2, height: 3 } if r.num == 7
));
assert!(matches!(
&elements[3],
Element::Annotation { page: 0, r, subtype }
if r.num == 8 && subtype.0 == "Link"
));
assert_eq!(elements.len(), 4);
}
#[tokio::test]
async fn content_ops_spans_reslice_to_the_same_op() {
let doc = AsyncDocument::from_bytes(simple_doc("ops")).await.unwrap();
let opts = ElementOpts {
physical: false,
logical: true,
pages: None,
content_ops: true,
};
let items = collect(&doc, opts).await;
let sync_doc = pdfboss_core::Document::load(simple_doc("ops")).unwrap();
let decoded = sync_doc.page(0).unwrap().content(&sync_doc).unwrap();
let ops: Vec<(pdfboss_core::content::Op, Span)> = items
.iter()
.filter_map(|item| match item {
Ok(Element::ContentOp {
op,
span_in_content,
..
}) => Some((op.clone(), *span_in_content)),
_ => None,
})
.collect();
assert!(!ops.is_empty());
let expected = pdfboss_core::content::parse_content(&decoded).unwrap();
let streamed: Vec<pdfboss_core::content::Op> =
ops.iter().map(|entry| entry.0.clone()).collect();
assert_eq!(streamed, expected);
for (op, span) in &ops {
let slice = &decoded[span.start as usize..span.end as usize];
let reparsed = pdfboss_core::content::parse_content(slice).unwrap();
assert_eq!(reparsed.len(), 1, "span {span:?} holds one op");
assert_eq!(&reparsed[0], op);
}
}
#[tokio::test]
async fn pages_filter_restricts_the_logical_layer() {
let doc = AsyncDocument::from_bytes(multi_page_doc(&["a", "b", "c"]))
.await
.unwrap();
let opts = ElementOpts {
physical: false,
logical: true,
pages: Some(vec![1]),
content_ops: false,
};
let elements: Vec<Element> = collect(&doc, opts)
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
let page_indices: Vec<usize> = elements
.iter()
.filter_map(|el| match el {
Element::Page { index, .. } => Some(*index),
_ => None,
})
.collect();
assert_eq!(page_indices, vec![1]);
assert!(elements.iter().all(|el| match el {
Element::Font { page, .. } => *page == Some(1),
_ => true,
}));
}
#[tokio::test]
async fn page_records_follow_document_order() {
let opts = ElementOpts {
physical: false,
logical: true,
pages: None,
content_ops: false,
};
let doc = AsyncDocument::from_bytes(multi_page_doc(&["a", "b", "c"]))
.await
.unwrap();
let elements: Vec<Element> = collect(&doc, opts.clone())
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
let async_pages: Vec<(usize, ObjRef)> = elements
.iter()
.filter_map(|el| match el {
Element::Page { index, r } => Some((*index, *r)),
_ => None,
})
.collect();
let sync_doc = pdfboss_core::Document::load(multi_page_doc(&["a", "b", "c"])).unwrap();
let sync_pages: Vec<(usize, ObjRef)> = sync_doc
.elements(opts)
.collect::<pdfboss_core::Result<Vec<_>>>()
.unwrap()
.into_iter()
.filter_map(|el| match el {
Element::Page { index, r } => Some((index, r)),
_ => None,
})
.collect();
assert_eq!(
async_pages, sync_pages,
"page order and object refs match the sync core walk exactly"
);
assert_eq!(
async_pages,
vec![
(0, ObjRef { num: 4, gen: 0 }),
(1, ObjRef { num: 6, gen: 0 }),
(2, ObjRef { num: 8, gen: 0 }),
]
);
}
#[tokio::test]
async fn inline_page_kid_yields_no_page_element() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(
2,
"<< /Type /Pages /Kids [<< /Type /Page /Parent 2 0 R \
/MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> >> \
3 0 R] /Count 2 >>",
);
b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>");
b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
assert_eq!(doc.page_count(), 2, "both children count as pages");
let opts = ElementOpts {
physical: false,
logical: true,
pages: None,
content_ops: false,
};
let elements: Vec<Element> = collect(&doc, opts)
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
let page_indices: Vec<usize> = elements
.iter()
.filter_map(|el| match el {
Element::Page { index, .. } => Some(*index),
_ => None,
})
.collect();
assert_eq!(
page_indices,
vec![1],
"the inline kid (index 0) yields no Page element; the Ref kid (index 1) still does"
);
let font_pages: Vec<Option<usize>> = elements
.iter()
.filter_map(|el| match el {
Element::Font { page, .. } => Some(*page),
_ => None,
})
.collect();
assert!(
font_pages.contains(&Some(0)),
"the inline page's own resources still yield child elements"
);
}
#[tokio::test]
async fn indirect_width_or_height_defaults_to_zero_matching_core() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /XObject << /Im0 7 0 R >> >> >>",
);
b.object(9, "2"); b.stream(
7,
"/Type /XObject /Subtype /Image /Width 9 0 R /Height 3 \
/ColorSpace /DeviceGray /BitsPerComponent 8",
&[0u8; 6],
);
let bytes = b.build(1);
let doc = AsyncDocument::from_bytes(bytes.clone()).await.unwrap();
let opts = ElementOpts {
physical: false,
logical: true,
pages: None,
content_ops: false,
};
let elements: Vec<Element> = collect(&doc, opts.clone())
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
let image = elements
.iter()
.find_map(|el| match el {
Element::Image { width, height, .. } => Some((*width, *height)),
_ => None,
})
.expect("image element present");
assert_eq!(
image,
(0, 3),
"an indirect /Width is not resolved: it defaults to 0, matching core"
);
let sync_doc = pdfboss_core::Document::load(bytes).unwrap();
let sync_image = sync_doc
.elements(opts)
.collect::<pdfboss_core::Result<Vec<_>>>()
.unwrap()
.into_iter()
.find_map(|el| match el {
Element::Image { width, height, .. } => Some((width, height)),
_ => None,
})
.expect("sync image element present");
assert_eq!(image, sync_image, "matches the sync core walk exactly");
}
#[tokio::test]
async fn content_ops_match_core_across_varied_operators() {
let content = "q 1 0 0 1 10 20 cm 0 0 10 10 re f Q \
BT /F1 12 Tf [(Hi) -250 (there)] TJ ET \
zzUnknownOp 1 2 \
BI /W 1 /H 1 /BPC 8 /CS /G ID \x01 EI";
let bytes = pdfboss_testkit::doc_with_graphics(content);
let doc = AsyncDocument::from_bytes(bytes.clone()).await.unwrap();
let opts = ElementOpts {
physical: false,
logical: true,
pages: None,
content_ops: true,
};
let items = collect(&doc, opts).await;
assert!(
items.iter().all(|item| item.is_ok()),
"no salvage errors expected on well-formed content: {items:?}"
);
let streamed: Vec<(pdfboss_core::content::Op, Span)> = items
.iter()
.filter_map(|item| match item {
Ok(Element::ContentOp {
op,
span_in_content,
..
}) => Some((op.clone(), *span_in_content)),
_ => None,
})
.collect();
let sync_doc = pdfboss_core::Document::load(bytes).unwrap();
let decoded = sync_doc.page(0).unwrap().content(&sync_doc).unwrap();
let expected = pdfboss_core::content::parse_content_spanned(&decoded).unwrap();
assert_eq!(
streamed, expected,
"op sequence and spans match core's own spanned parse exactly"
);
}
#[tokio::test]
async fn indirect_resource_category_still_enumerates() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font 6 0 R >> >>",
);
b.object(6, "<< /F1 5 0 R >>");
b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
let bytes = b.build(1);
let doc = AsyncDocument::from_bytes(bytes.clone()).await.unwrap();
let opts = ElementOpts {
physical: false,
logical: true,
pages: None,
content_ops: false,
};
let elements: Vec<Element> = collect(&doc, opts.clone())
.await
.into_iter()
.map(|item| item.unwrap())
.collect();
let fonts: Vec<(ObjRef, String, Option<String>)> = elements
.iter()
.filter_map(|el| match el {
Element::Font {
r,
subtype,
base_font,
..
} => Some((
*r,
subtype.0.clone(),
base_font.as_ref().map(|n| n.0.clone()),
)),
_ => None,
})
.collect();
assert_eq!(
fonts,
vec![(
ObjRef { num: 5, gen: 0 },
"Type1".to_string(),
Some("Helvetica".to_string())
)],
"an indirect /Font category dict is still resolved and enumerated"
);
let sync_doc = pdfboss_core::Document::load(bytes).unwrap();
let sync_fonts: Vec<(ObjRef, String, Option<String>)> = sync_doc
.elements(opts)
.collect::<pdfboss_core::Result<Vec<_>>>()
.unwrap()
.into_iter()
.filter_map(|el| match el {
Element::Font {
r,
subtype,
base_font,
..
} => Some((
r,
subtype.0.clone(),
base_font.as_ref().map(|n| n.0.clone()),
)),
_ => None,
})
.collect();
assert_eq!(fonts, sync_fonts, "matches the sync core walk exactly");
}
}