Skip to main content

pdfboss_core/
structure.rs

1//! Structure-tree reading order (ISO 32000-1 §14.7): where a page's
2//! marked-content sequences sit in the document's logical structure, so a
3//! tagged page can be read in the order its author declared.
4
5use std::sync::Arc;
6
7use crate::document::Page;
8use crate::hash::{FastMap, FastSet};
9use crate::object::{Dict, ObjRef, Object};
10use crate::source::AsyncObjectSource;
11
12/// Maximum number of ancestors walked from an element up to the root.
13/// Deeper ancestry reads as malformed and leaves the element unranked.
14const MAX_ELEMENT_DEPTH: usize = 64;
15
16/// Maximum parent-tree nodes visited for one lookup: past it the lookup
17/// gives up, so a cyclic `/Kids` graph cannot spin the walk.
18const MAX_NUMBER_TREE_NODES: usize = 4096;
19
20/// One marked-content sequence: its `/MCID`, and the `/StructParents` key of
21/// the content stream it appeared in: the page's, or a form XObject's own
22/// when the form declares one (§14.7.4.4).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct MarkedContentId {
25    pub parents: u32,
26    pub mcid: u32,
27}
28
29/// The document's structure tree root (`/StructTreeRoot`), loaded once per
30/// document and asked per page where that page's marked content sits in
31/// the tree. `/MarkInfo` is never consulted: a tree with leaves counts,
32/// whatever the file says about itself.
33#[derive(Debug, Clone, PartialEq)]
34pub struct StructureTree {
35    root: Dict,
36    root_ref: Option<ObjRef>,
37}
38
39impl StructureTree {
40    /// Loads the catalog's `/StructTreeRoot`, or `None` when the document
41    /// declares none, or when the entry is missing or unreadable, which
42    /// leaves every page in content order.
43    pub async fn load_with<S: AsyncObjectSource>(src: &S, trailer: &Dict) -> Option<StructureTree> {
44        let root = trailer.get("Root")?;
45        let catalog = src.resolve(root).await.ok()?;
46        let entry = catalog.as_dict()?.get("StructTreeRoot")?;
47        let root_ref = entry.as_ref();
48        let resolved = src.resolve(entry).await.ok()?;
49        let root = resolved.as_dict()?.clone();
50        Some(StructureTree { root, root_ref })
51    }
52
53    /// Ranks `ids`, one page's marked-content sequences, by their position in
54    /// the tree's depth-first order: 0 for the first the tree reaches, and so
55    /// on. An id the tree never reaches (untagged content, a key the parent
56    /// tree lacks, an element whose ancestry is broken) is absent, so an
57    /// empty map means the page has no leaves in the tree.
58    ///
59    /// The lookup goes through the parent tree (`/ParentTree`, keyed by
60    /// `/StructParents`) and each element's `/P` chain, so it costs the page's
61    /// own elements, never a walk of the whole tree.
62    pub async fn ranks_with<S: AsyncObjectSource>(
63        &self,
64        src: &S,
65        page: &Page,
66        ids: &[MarkedContentId],
67    ) -> FastMap<MarkedContentId, u32> {
68        let mut ranks: FastMap<MarkedContentId, u32> = FastMap::default();
69        let Some(parent_tree) = self.root.get("ParentTree") else {
70            return ranks;
71        };
72        let Some(parent_tree) = resolved_dict(src, parent_tree).await else {
73            return ranks;
74        };
75        let mut walk = Walk {
76            src,
77            page_ref: page.object_ref(),
78            root_ref: self.root_ref,
79            dicts: FastMap::default(),
80            paths: FastMap::default(),
81            parents: FastMap::default(),
82        };
83        let mut keyed: Vec<(MarkedContentId, Vec<u32>)> = Vec::new();
84        let mut seen: FastSet<MarkedContentId> = FastSet::default();
85        for id in ids {
86            if !seen.insert(*id) {
87                continue;
88            }
89            let Some(key) = walk.key_of(&parent_tree, *id).await else {
90                continue;
91            };
92            keyed.push((*id, key));
93        }
94        keyed.sort_by(|a, b| a.1.cmp(&b.1));
95        for (rank, (id, _)) in keyed.into_iter().enumerate() {
96            ranks.insert(id, rank as u32);
97        }
98        ranks
99    }
100}
101
102/// One page's walk through the tree: the dictionaries it has already read
103/// and the paths it has already computed, so a paragraph's ancestry is
104/// walked once for every marked-content sequence it contains.
105struct Walk<'a, S> {
106    src: &'a S,
107    page_ref: Option<ObjRef>,
108    root_ref: Option<ObjRef>,
109    dicts: FastMap<ObjRef, Option<Arc<Dict>>>,
110    /// Each element's kid-index path from the root, or `None` once its
111    /// ancestry proved unwalkable.
112    paths: FastMap<ObjRef, Option<Arc<Vec<u32>>>>,
113    /// The parent tree's array for each `/StructParents` key seen.
114    parents: FastMap<u32, Option<Arc<Vec<Object>>>>,
115}
116
117impl<S: AsyncObjectSource> Walk<'_, S> {
118    /// The sort key of one marked-content sequence: its element's path from
119    /// the root, then its own index among the element's kids.
120    async fn key_of(&mut self, parent_tree: &Dict, id: MarkedContentId) -> Option<Vec<u32>> {
121        let elements = self.parent_array(parent_tree, id.parents).await?;
122        let element = elements.get(id.mcid as usize)?.as_ref()?;
123        let path = self.path_of(element).await?;
124        let dict = self.dict(element).await?;
125        let index = self.mcid_index(&dict, id.mcid).await?;
126        let mut key = Vec::with_capacity(path.len() + 1);
127        key.extend_from_slice(&path);
128        key.push(index);
129        Some(key)
130    }
131
132    /// The parent tree's entry for a `/StructParents` key: the array whose
133    /// index is a marked-content id and whose value is that id's element.
134    async fn parent_array(&mut self, parent_tree: &Dict, key: u32) -> Option<Arc<Vec<Object>>> {
135        if let Some(cached) = self.parents.get(&key) {
136            return cached.clone();
137        }
138        let found = match number_tree_lookup(self.src, parent_tree, i64::from(key)).await {
139            Some(entry) => match self.src.resolve(&entry).await.ok()? {
140                Object::Array(items) => Some(Arc::new(items)),
141                _ => None,
142            },
143            None => None,
144        };
145        self.parents.insert(key, found.clone());
146        found
147    }
148
149    async fn dict(&mut self, r: ObjRef) -> Option<Arc<Dict>> {
150        if let Some(cached) = self.dicts.get(&r) {
151            return cached.clone();
152        }
153        let loaded = match self.src.get(r).await.ok()? {
154            Object::Dict(dict) => Some(Arc::new(dict)),
155            Object::Stream(stream) => Some(Arc::new(stream.dict)),
156            _ => None,
157        };
158        self.dicts.insert(r, loaded.clone());
159        loaded
160    }
161
162    /// An element's kid-index path from the root: the ancestry is followed
163    /// up through `/P` until it reaches the structure tree root, then each
164    /// ancestor's index among its parent's kids is read on the way back down.
165    /// Every ancestor's own path is remembered as a by-product.
166    async fn path_of(&mut self, element: ObjRef) -> Option<Arc<Vec<u32>>> {
167        if let Some(cached) = self.paths.get(&element) {
168            return cached.clone();
169        }
170        let mut chain: Vec<ObjRef> = vec![element];
171        // The ancestor the climb stopped at, with its path: the root
172        // itself, or an ancestor whose path an earlier climb computed.
173        let mut stop: Option<(ObjRef, bool, Arc<Vec<u32>>)> = None;
174        let mut current = element;
175        for _ in 0..MAX_ELEMENT_DEPTH {
176            let Some(dict) = self.dict(current).await else {
177                break;
178            };
179            let Some(parent) = dict.get("P").and_then(Object::as_ref) else {
180                break;
181            };
182            if self.is_root(parent).await {
183                stop = Some((parent, true, Arc::new(Vec::new())));
184                break;
185            }
186            if let Some(cached) = self.paths.get(&parent) {
187                stop = cached.clone().map(|path| (parent, false, path));
188                break;
189            }
190            chain.push(parent);
191            current = parent;
192        }
193        let Some((mut parent_ref, mut parent_is_root, known)) = stop else {
194            for r in chain {
195                self.paths.insert(r, None);
196            }
197            return None;
198        };
199        // From the topmost unresolved ancestor down to the element itself.
200        let mut path: Vec<u32> = (*known).clone();
201        let mut resolved: Option<Arc<Vec<u32>>> = None;
202        for child in chain.into_iter().rev() {
203            let index = if parent_is_root {
204                self.root_kid_index(child).await
205            } else {
206                match self.dict(parent_ref).await {
207                    Some(parent) => kid_index(&parent, child),
208                    None => None,
209                }
210            };
211            let Some(index) = index else {
212                self.paths.insert(child, None);
213                return None;
214            };
215            path.push(index);
216            let shared = Arc::new(path.clone());
217            self.paths.insert(child, Some(shared.clone()));
218            resolved = Some(shared);
219            parent_ref = child;
220            parent_is_root = false;
221        }
222        resolved
223    }
224
225    /// Whether `r` is the structure tree root: the reference the catalog
226    /// named, or failing that a dictionary typed `/StructTreeRoot`.
227    async fn is_root(&mut self, r: ObjRef) -> bool {
228        if self.root_ref == Some(r) {
229            return true;
230        }
231        let Some(dict) = self.dict(r).await else {
232            return false;
233        };
234        dict.get_name("Type")
235            .is_some_and(|n| n.0 == "StructTreeRoot")
236    }
237
238    /// The index of a top-level element among the root's `/K` kids.
239    async fn root_kid_index(&mut self, child: ObjRef) -> Option<u32> {
240        let root_ref = self.root_ref?;
241        let root = self.dict(root_ref).await?;
242        kid_index(&root, child)
243    }
244
245    /// The index among `element`'s kids of the marked-content sequence
246    /// numbered `mcid` on this page: a bare integer (the element's `/Pg`
247    /// page) or a marked-content reference dictionary naming the page. A
248    /// direct match is taken first; only when there is none are the
249    /// indirect kids read, in case the reference dictionary is one of them.
250    async fn mcid_index(&mut self, element: &Dict, mcid: u32) -> Option<u32> {
251        let kids = kids_of(element);
252        let page_ref = self.page_ref;
253        let element_page = element.get_ref("Pg");
254        let direct = kids.iter().position(|kid| match kid {
255            Object::Int(n) => {
256                u32::try_from(*n).is_ok_and(|n| n == mcid) && on_page(element_page, page_ref)
257            }
258            Object::Dict(d) => is_mcr(d, mcid) && on_page(d.get_ref("Pg"), page_ref),
259            _ => false,
260        });
261        if let Some(index) = direct {
262            return u32::try_from(index).ok();
263        }
264        for (index, kid) in kids.iter().enumerate() {
265            let Some(r) = kid.as_ref() else {
266                continue;
267            };
268            let Some(dict) = self.dict(r).await else {
269                continue;
270            };
271            if is_mcr(&dict, mcid) && on_page(dict.get_ref("Pg"), page_ref) {
272                return u32::try_from(index).ok();
273            }
274        }
275        None
276    }
277}
278
279/// Whether a kid's `/Pg` names this page. Either side unknown reads as a
280/// match: a page inlined into `/Kids` has no reference to compare, and a
281/// bare integer kid under an element without `/Pg` has nothing to compare
282/// against.
283fn on_page(pg: Option<ObjRef>, page_ref: Option<ObjRef>) -> bool {
284    match (pg, page_ref) {
285        (Some(pg), Some(page)) => pg == page,
286        _ => true,
287    }
288}
289
290/// Whether `dict` is the marked-content reference for `mcid`.
291fn is_mcr(dict: &Dict, mcid: u32) -> bool {
292    dict.get_int("MCID")
293        .and_then(|n| u32::try_from(n).ok())
294        .is_some_and(|n| n == mcid)
295}
296
297/// An element's `/K` as a list: a single kid stands alone, an array is its
298/// items, nothing is empty.
299fn kids_of(element: &Dict) -> Vec<Object> {
300    match element.get("K") {
301        Some(Object::Array(items)) => items.clone(),
302        Some(single) => vec![single.clone()],
303        None => Vec::new(),
304    }
305}
306
307/// The index of `child` among `parent`'s kids, by reference identity.
308fn kid_index(parent: &Dict, child: ObjRef) -> Option<u32> {
309    let index = kids_of(parent)
310        .iter()
311        .position(|kid| kid.as_ref() == Some(child))?;
312    u32::try_from(index).ok()
313}
314
315/// Resolves `o` to a dictionary, a stream's dictionary included.
316async fn resolved_dict<S: AsyncObjectSource>(src: &S, o: &Object) -> Option<Dict> {
317    match src.resolve(o).await.ok()? {
318        Object::Dict(dict) => Some(dict),
319        Object::Stream(stream) => Some(stream.dict),
320        _ => None,
321    }
322}
323
324/// Looks `key` up in a number tree (ISO 32000-1 §7.9.7): `/Nums` holds the
325/// leaf pairs, `/Kids` the subtrees, each with the `/Limits` its keys fall
326/// in. The value comes back unresolved. Malformed nodes are skipped, and
327/// the walk stops after [`MAX_NUMBER_TREE_NODES`] nodes.
328async fn number_tree_lookup<S: AsyncObjectSource>(
329    src: &S,
330    root: &Dict,
331    key: i64,
332) -> Option<Object> {
333    let mut pending: Vec<Dict> = vec![root.clone()];
334    let mut visited = 0usize;
335    while let Some(node) = pending.pop() {
336        visited += 1;
337        if visited > MAX_NUMBER_TREE_NODES {
338            return None;
339        }
340        if let Some(nums) = node.get("Nums") {
341            if let Some(found) = leaf_value(src, nums, key).await {
342                return Some(found);
343            }
344        }
345        let Some(kids) = node.get("Kids") else {
346            continue;
347        };
348        let Ok(Object::Array(kids)) = src.resolve(kids).await else {
349            continue;
350        };
351        for kid in kids.iter().rev() {
352            let Some(kid) = resolved_dict(src, kid).await else {
353                continue;
354            };
355            if within_limits(src, &kid, key).await {
356                pending.push(kid);
357            }
358        }
359    }
360    None
361}
362
363/// Whether `key` falls in a node's `/Limits`; a node without readable
364/// limits is searched regardless.
365async fn within_limits<S: AsyncObjectSource>(src: &S, node: &Dict, key: i64) -> bool {
366    let Some(limits) = node.get("Limits") else {
367        return true;
368    };
369    let Ok(Object::Array(limits)) = src.resolve(limits).await else {
370        return true;
371    };
372    match (bound(src, &limits, 0).await, bound(src, &limits, 1).await) {
373        (Some(lo), Some(hi)) => lo <= key && key <= hi,
374        _ => true,
375    }
376}
377
378/// One `/Limits` bound as an integer, resolving an indirect one.
379async fn bound<S: AsyncObjectSource>(src: &S, limits: &[Object], i: usize) -> Option<i64> {
380    let o = limits.get(i)?;
381    src.resolve(o).await.ok()?.as_int()
382}
383
384/// The value paired with `key` in a `/Nums` array, unresolved.
385async fn leaf_value<S: AsyncObjectSource>(src: &S, nums: &Object, key: i64) -> Option<Object> {
386    let Ok(Object::Array(pairs)) = src.resolve(nums).await else {
387        return None;
388    };
389    for [number, value] in pairs.as_chunks::<2>().0 {
390        let found = match number {
391            Object::Int(n) => *n == key,
392            other => src.resolve(other).await.ok().and_then(|v| v.as_int()) == Some(key),
393        };
394        if found {
395            return Some(value.clone());
396        }
397    }
398    None
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::{block_on, Document, Immediate};
405    use pdfboss_testkit::PdfBuilder;
406
407    fn id(parents: u32, mcid: u32) -> MarkedContentId {
408        MarkedContentId { parents, mcid }
409    }
410
411    /// A one-page document whose catalog names object 10 as the structure
412    /// tree root; `objects` supplies the tree (10 and up) and `page_extra`
413    /// lands in the page dictionary.
414    fn tagged_doc(page_extra: &str, objects: &[(u32, &str)]) -> Document {
415        let mut b = PdfBuilder::new();
416        b.object(
417            1,
418            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 10 0 R >>",
419        );
420        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
421        b.object(
422            3,
423            &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] {page_extra} >>"),
424        );
425        for (num, body) in objects {
426            b.object(*num, body);
427        }
428        Document::load(b.build(1)).expect("load")
429    }
430
431    /// Two paragraphs on one page, the left one holding ids 0 and 2, the
432    /// right one 1 and 3: tree order is 0, 2, 1, 3.
433    fn two_paragraphs(parent_tree: &str) -> Document {
434        tagged_doc(
435            "/StructParents 0",
436            &[
437                (
438                    10,
439                    "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
440                ),
441                (
442                    11,
443                    "<< /Type /StructElem /S /Document /P 10 0 R /K [13 0 R 14 0 R] >>",
444                ),
445                (12, parent_tree),
446                (
447                    13,
448                    "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0 2] >>",
449                ),
450                (
451                    14,
452                    "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [1 3] >>",
453                ),
454            ],
455        )
456    }
457
458    fn ranks(doc: &Document, ids: &[MarkedContentId]) -> FastMap<MarkedContentId, u32> {
459        let tree = doc.structure_tree().expect("tree");
460        let page = doc.page(0).unwrap();
461        block_on(tree.ranks_with(&Immediate(doc), &page, ids))
462    }
463
464    #[test]
465    fn no_struct_tree_root_means_no_tree() {
466        let mut b = PdfBuilder::new();
467        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
468        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
469        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
470        let doc = Document::load(b.build(1)).unwrap();
471        assert!(doc.structure_tree().is_none());
472    }
473
474    #[test]
475    fn ranks_follow_the_tree_not_the_ids() {
476        let doc = two_paragraphs("<< /Nums [0 [13 0 R 14 0 R 13 0 R 14 0 R]] >>");
477        let ranks = ranks(&doc, &[id(0, 2), id(0, 3), id(0, 0), id(0, 1)]);
478        assert_eq!(ranks[&id(0, 0)], 0);
479        assert_eq!(ranks[&id(0, 2)], 1);
480        assert_eq!(ranks[&id(0, 1)], 2);
481        assert_eq!(ranks[&id(0, 3)], 3);
482    }
483
484    #[test]
485    fn parent_tree_kids_and_limits_are_descended() {
486        let doc = tagged_doc(
487            "/StructParents 7",
488            &[
489                (
490                    10,
491                    "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
492                ),
493                (
494                    11,
495                    "<< /Type /StructElem /S /Document /P 10 0 R /K [13 0 R] >>",
496                ),
497                (12, "<< /Kids [15 0 R 16 0 R] >>"),
498                (
499                    13,
500                    "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0] >>",
501                ),
502                (15, "<< /Limits [0 3] /Nums [0 [] 3 []] >>"),
503                (16, "<< /Limits [7 9] /Nums [7 [13 0 R] 9 []] >>"),
504            ],
505        );
506        let ranks = ranks(&doc, &[id(7, 0)]);
507        assert_eq!(ranks[&id(7, 0)], 0);
508    }
509
510    #[test]
511    fn a_page_without_a_parent_tree_entry_has_no_ranks() {
512        let doc = two_paragraphs("<< /Nums [5 [13 0 R]] >>");
513        assert!(ranks(&doc, &[id(0, 0), id(0, 1)]).is_empty());
514    }
515
516    #[test]
517    fn untagged_and_out_of_range_ids_are_absent() {
518        let doc = two_paragraphs("<< /Nums [0 [13 0 R 14 0 R 13 0 R 14 0 R]] >>");
519        let ranks = ranks(&doc, &[id(0, 0), id(0, 9), id(4, 0)]);
520        assert_eq!(ranks.len(), 1);
521        assert_eq!(ranks[&id(0, 0)], 0);
522    }
523
524    #[test]
525    fn a_broken_ancestry_leaves_only_that_element_unranked() {
526        let doc = tagged_doc(
527            "/StructParents 0",
528            &[
529                (
530                    10,
531                    "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
532                ),
533                (
534                    11,
535                    "<< /Type /StructElem /S /Document /P 10 0 R /K [13 0 R] >>",
536                ),
537                (12, "<< /Nums [0 [13 0 R 14 0 R]] >>"),
538                (
539                    13,
540                    "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0] >>",
541                ),
542                // Not among its parent's kids: its path cannot be read.
543                (
544                    14,
545                    "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [1] >>",
546                ),
547            ],
548        );
549        let ranks = ranks(&doc, &[id(0, 0), id(0, 1)]);
550        assert_eq!(ranks.len(), 1);
551        assert_eq!(ranks[&id(0, 0)], 0);
552    }
553
554    #[test]
555    fn marked_content_references_name_their_page() {
556        // One element spanning two pages: the same id number on each. The
557        // reference naming this page wins the index, the other is skipped.
558        let doc = tagged_doc(
559            "/StructParents 0",
560            &[
561                (
562                    10,
563                    "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
564                ),
565                (
566                    11,
567                    "<< /Type /StructElem /S /P /P 10 0 R \
568                     /K [<< /Type /MCR /Pg 99 0 R /MCID 0 >> 15 0 R] >>",
569                ),
570                (12, "<< /Nums [0 [11 0 R]] >>"),
571                (15, "<< /Type /MCR /Pg 3 0 R /MCID 0 >>"),
572            ],
573        );
574        let ranks = ranks(&doc, &[id(0, 0)]);
575        assert_eq!(ranks[&id(0, 0)], 0);
576        let tree = doc.structure_tree().unwrap();
577        let page = doc.page(0).unwrap();
578        let mut walk = Walk {
579            src: &Immediate(&doc),
580            page_ref: page.object_ref(),
581            root_ref: Some(ObjRef { num: 10, gen: 0 }),
582            dicts: FastMap::default(),
583            paths: FastMap::default(),
584            parents: FastMap::default(),
585        };
586        let element = block_on(walk.dict(ObjRef { num: 11, gen: 0 })).unwrap();
587        assert_eq!(block_on(walk.mcid_index(&element, 0)), Some(1));
588        drop(tree);
589    }
590
591    #[test]
592    fn a_bare_integer_on_another_page_is_not_this_page() {
593        let doc = tagged_doc(
594            "/StructParents 0",
595            &[
596                (
597                    10,
598                    "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
599                ),
600                (
601                    11,
602                    "<< /Type /StructElem /S /P /P 10 0 R /Pg 99 0 R /K [0] >>",
603                ),
604                (12, "<< /Nums [0 [11 0 R]] >>"),
605            ],
606        );
607        assert!(ranks(&doc, &[id(0, 0)]).is_empty());
608    }
609
610    #[test]
611    fn a_form_key_ranks_alongside_the_page() {
612        // The form's marked content (key 1) is a child of the second
613        // paragraph; the page's (key 0) fills the first.
614        let doc = tagged_doc(
615            "/StructParents 0",
616            &[
617                (
618                    10,
619                    "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
620                ),
621                (
622                    11,
623                    "<< /Type /StructElem /S /Document /P 10 0 R /K [13 0 R 14 0 R] >>",
624                ),
625                (12, "<< /Nums [0 [13 0 R] 1 [14 0 R]] >>"),
626                (
627                    13,
628                    "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0] >>",
629                ),
630                (
631                    14,
632                    "<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0] >>",
633                ),
634            ],
635        );
636        let ranks = ranks(&doc, &[id(1, 0), id(0, 0)]);
637        assert_eq!(ranks[&id(0, 0)], 0);
638        assert_eq!(ranks[&id(1, 0)], 1);
639    }
640
641    #[test]
642    fn a_single_kid_needs_no_array() {
643        let doc = tagged_doc(
644            "/StructParents 0",
645            &[
646                (
647                    10,
648                    "<< /Type /StructTreeRoot /K 11 0 R /ParentTree 12 0 R >>",
649                ),
650                (11, "<< /Type /StructElem /S /P /P 10 0 R /Pg 3 0 R /K 0 >>"),
651                (12, "<< /Nums [0 [11 0 R]] >>"),
652            ],
653        );
654        assert_eq!(ranks(&doc, &[id(0, 0)])[&id(0, 0)], 0);
655    }
656}