Skip to main content

zpdf_document/
structure.rs

1//! Logical structure tree / Tagged PDF (ISO 32000-1 §14.7–14.8). A *tagged* PDF
2//! carries, alongside its page content, a tree of **structure elements** that
3//! describes the document's logical organization — its headings, paragraphs,
4//! lists, tables, figures and their reading order — independent of how that
5//! content happens to be laid out on the page. This is what makes a PDF
6//! accessible (a screen reader walks the structure tree, not the page) and what
7//! lets a consumer recover semantic structure rather than a bag of glyphs.
8//!
9//! The catalog's `/StructTreeRoot` roots the tree (§14.7.2). Each node is a
10//! *structure element* dictionary (`/Type /StructElem`) carrying:
11//!
12//! * `/S` — the **structure type** (the *role*): a standard type such as `P`,
13//!   `H1`, `Table`, `Figure`, or a producer-defined type mapped to a standard
14//!   one by the root's `/RoleMap`.
15//! * `/K` — the element's **kids**, a single value or an array mixing: nested
16//!   structure elements (by reference or inline dict), bare integers
17//!   (*marked-content identifiers* — MCIDs — pointing into a page's content
18//!   stream), marked-content reference dicts (`/Type /MCR`), and object
19//!   reference dicts (`/Type /OBJR`, e.g. for an annotation).
20//! * `/Pg` — the page whose content stream the element's MCIDs index; inherited
21//!   by descendants that don't carry their own.
22//! * `/Alt`, `/ActualText`, `/E`, `/T`, `/Lang` — accessibility text and
23//!   metadata: an alternate description, the exact replacement text, an
24//!   abbreviation expansion, a title, and a language tag.
25//!
26//! This module reads `/StructTreeRoot` once into a navigable [`StructTree`]. Like
27//! the other document readers it only walks the object graph — nothing here
28//! renders — and it runs only when explicitly called, never during `open` or
29//! rendering. Every descent is bounded (depth cap, a per-reference visited set
30//! seeded with the tree-root reference, a shared node/entry budget, role-map
31//! resolution depth, and per-string length caps) so a malformed or adversarial
32//! tree cannot hang, recurse without bound, or exhaust memory.
33
34use std::collections::{HashMap, HashSet};
35
36use zpdf_core::{ObjectId, PdfDict, PdfObject};
37use zpdf_parser::PdfFile;
38
39use crate::obj_util::{catalog_dict, resolve_dict, text};
40use crate::Catalog;
41
42/// Maximum nesting depth of the structure tree before a subtree is pruned
43/// (mirrors the outline/number-tree bounds used elsewhere).
44const MAX_STRUCT_DEPTH: usize = 64;
45/// Cap on structure elements *and* kid-array entries materialized while walking
46/// the tree — a shared budget that bounds a crafted huge or densely-referenced
47/// tree. Far above any real document.
48const MAX_STRUCT_ELEMENTS: usize = 500_000;
49/// Cap on transitive `/RoleMap` resolution (custom → … → standard type), so a
50/// `RoleMap` that maps a name through a cycle cannot loop.
51const MAX_ROLE_MAP_DEPTH: usize = 32;
52/// Cap on `/RoleMap` entries read — a real map has a handful; this bounds a
53/// crafted one.
54const MAX_ROLE_MAP_ENTRIES: usize = 65_536;
55/// Cap (in `char`s) on a per-element text string (`/Alt`, `/ActualText`, `/T`,
56/// `/E`, `/Lang`). Real values are short; this bounds an adversarial one.
57const MAX_TEXT_CHARS: usize = 64 * 1024;
58
59/// A standard structure type (ISO 32000-1 §14.8.4), the *role* of a structure
60/// element after resolving its `/S` through the document's `/RoleMap`. A type
61/// outside the standard set (and not mapped onto it) is [`StructRole::Other`].
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum StructRole {
64    // Grouping elements (§14.8.4.3).
65    Document,
66    Part,
67    Art,
68    Sect,
69    Div,
70    BlockQuote,
71    Caption,
72    /// Table of contents (`TOC`).
73    Toc,
74    /// Table-of-contents item (`TOCI`).
75    Toci,
76    Index,
77    NonStruct,
78    Private,
79    // Paragraphlike block-level elements (§14.8.4.4).
80    P,
81    H,
82    H1,
83    H2,
84    H3,
85    H4,
86    H5,
87    H6,
88    // List elements.
89    L,
90    /// List item (`LI`).
91    Li,
92    /// List-item label (`Lbl`).
93    Lbl,
94    /// List-item body (`LBody`).
95    LBody,
96    // Table elements.
97    Table,
98    /// Table row (`TR`).
99    Tr,
100    /// Table header cell (`TH`).
101    Th,
102    /// Table data cell (`TD`).
103    Td,
104    /// Table header row group (`THead`).
105    THead,
106    /// Table body row group (`TBody`).
107    TBody,
108    /// Table footer row group (`TFoot`).
109    TFoot,
110    // Inline-level elements (§14.8.4.5).
111    Span,
112    Quote,
113    Note,
114    Reference,
115    BibEntry,
116    Code,
117    Link,
118    Annot,
119    // Ruby / Warichu sub-elements.
120    Ruby,
121    /// Ruby base text (`RB`).
122    Rb,
123    /// Ruby annotation text (`RT`).
124    Rt,
125    /// Ruby punctuation (`RP`).
126    Rp,
127    Warichu,
128    /// Warichu text (`WT`).
129    Wt,
130    /// Warichu punctuation (`WP`).
131    Wp,
132    // Illustration elements (§14.8.4.6).
133    Figure,
134    Formula,
135    Form,
136    /// A non-standard type: the resolved type name (possibly the producer's own,
137    /// when `/RoleMap` did not map it onto a standard type).
138    Other(String),
139}
140
141impl StructRole {
142    /// Classify a (role-map-resolved) structure-type *name* into a standard role,
143    /// or [`StructRole::Other`] when it is not one of the standard types.
144    fn from_name(name: &str) -> Self {
145        use StructRole::*;
146        match name {
147            "Document" => Document,
148            "Part" => Part,
149            "Art" => Art,
150            "Sect" => Sect,
151            "Div" => Div,
152            "BlockQuote" => BlockQuote,
153            "Caption" => Caption,
154            "TOC" => Toc,
155            "TOCI" => Toci,
156            "Index" => Index,
157            "NonStruct" => NonStruct,
158            "Private" => Private,
159            "P" => P,
160            "H" => H,
161            "H1" => H1,
162            "H2" => H2,
163            "H3" => H3,
164            "H4" => H4,
165            "H5" => H5,
166            "H6" => H6,
167            "L" => L,
168            "LI" => Li,
169            "Lbl" => Lbl,
170            "LBody" => LBody,
171            "Table" => Table,
172            "TR" => Tr,
173            "TH" => Th,
174            "TD" => Td,
175            "THead" => THead,
176            "TBody" => TBody,
177            "TFoot" => TFoot,
178            "Span" => Span,
179            "Quote" => Quote,
180            "Note" => Note,
181            "Reference" => Reference,
182            "BibEntry" => BibEntry,
183            "Code" => Code,
184            "Link" => Link,
185            "Annot" => Annot,
186            "Ruby" => Ruby,
187            "RB" => Rb,
188            "RT" => Rt,
189            "RP" => Rp,
190            "Warichu" => Warichu,
191            "WT" => Wt,
192            "WP" => Wp,
193            "Figure" => Figure,
194            "Formula" => Formula,
195            "Form" => Form,
196            other => Other(other.to_string()),
197        }
198    }
199
200    /// The canonical PDF type name for this role (`Toc` → `"TOC"`, `Li` → `"LI"`,
201    /// …). For [`StructRole::Other`] this is the resolved producer type name.
202    pub fn as_str(&self) -> &str {
203        use StructRole::*;
204        match self {
205            Document => "Document",
206            Part => "Part",
207            Art => "Art",
208            Sect => "Sect",
209            Div => "Div",
210            BlockQuote => "BlockQuote",
211            Caption => "Caption",
212            Toc => "TOC",
213            Toci => "TOCI",
214            Index => "Index",
215            NonStruct => "NonStruct",
216            Private => "Private",
217            P => "P",
218            H => "H",
219            H1 => "H1",
220            H2 => "H2",
221            H3 => "H3",
222            H4 => "H4",
223            H5 => "H5",
224            H6 => "H6",
225            L => "L",
226            Li => "LI",
227            Lbl => "Lbl",
228            LBody => "LBody",
229            Table => "Table",
230            Tr => "TR",
231            Th => "TH",
232            Td => "TD",
233            THead => "THead",
234            TBody => "TBody",
235            TFoot => "TFoot",
236            Span => "Span",
237            Quote => "Quote",
238            Note => "Note",
239            Reference => "Reference",
240            BibEntry => "BibEntry",
241            Code => "Code",
242            Link => "Link",
243            Annot => "Annot",
244            Ruby => "Ruby",
245            Rb => "RB",
246            Rt => "RT",
247            Rp => "RP",
248            Warichu => "Warichu",
249            Wt => "WT",
250            Wp => "WP",
251            Figure => "Figure",
252            Formula => "Formula",
253            Form => "Form",
254            Other(s) => s,
255        }
256    }
257
258    /// True when this is a recognized standard structure type (not
259    /// [`StructRole::Other`]).
260    pub fn is_standard(&self) -> bool {
261        !matches!(self, StructRole::Other(_))
262    }
263
264    /// True for a heading role (`H` or `H1`…`H6`).
265    pub fn is_heading(&self) -> bool {
266        use StructRole::*;
267        matches!(self, H | H1 | H2 | H3 | H4 | H5 | H6)
268    }
269
270    /// True for a block-level role — a grouping element or a block-level
271    /// structure element (paragraph, heading, list item, table row, figure …)
272    /// that begins on its own line when serializing the tree to reading-ordered
273    /// text. Inline-level roles (`Span`, `Quote`, `Link`, `Reference`,
274    /// ruby/warichu …), the transparent `NonStruct`/`Private` grouping, and the
275    /// *intra-line* structure roles flow inline: a list item's label and body
276    /// (`Lbl`/`LBody`) read on one line with the item, and a row's cells
277    /// (`TH`/`TD`, and the `THead`/`TBody`/`TFoot` row groups) read across the
278    /// row rather than each on its own line.
279    pub fn is_block_level(&self) -> bool {
280        use StructRole::*;
281        matches!(
282            self,
283            Document
284                | Part
285                | Art
286                | Sect
287                | Div
288                | BlockQuote
289                | Caption
290                | Toc
291                | Toci
292                | Index
293                | P
294                | H
295                | H1
296                | H2
297                | H3
298                | H4
299                | H5
300                | H6
301                | L
302                | Li
303                | Table
304                | Tr
305                // `Note`: spec-inline (§14.8.4.5), but a footnote/endnote reads
306                // as its own block in logical order, like mainstream extractors.
307                | Note
308                | Figure
309                | Formula
310        )
311    }
312}
313
314/// A kid of a structure element: a nested element, a marked-content sequence in
315/// a page's content stream, or a whole referenced object (`/OBJR`).
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum StructKid {
318    /// A nested structure element.
319    Element(StructElem),
320    /// A marked-content sequence — a bare integer kid or a `/Type /MCR` dict.
321    /// `page` is the 0-based index of the page whose content stream `mcid`
322    /// indexes (the element's effective `/Pg`), or `None` when unresolved.
323    MarkedContent {
324        /// 0-based page index of the content stream this MCID indexes.
325        page: Option<usize>,
326        /// The marked-content identifier (`/MCID`).
327        mcid: i64,
328    },
329    /// A reference to a whole object (`/Type /OBJR`), e.g. an annotation that
330    /// participates in the logical structure.
331    Object {
332        /// 0-based page index the object appears on (`/Pg`), when resolvable.
333        page: Option<usize>,
334        /// The referenced object.
335        obj: ObjectId,
336    },
337}
338
339/// One structure element (`/Type /StructElem`): a node of the logical tree.
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct StructElem {
342    /// `/S` resolved through `/RoleMap` and classified into a standard role.
343    pub role: StructRole,
344    /// The original `/S` type name, before `/RoleMap` resolution (equals
345    /// [`StructRole::as_str`] for a standard, unmapped type).
346    pub raw_type: String,
347    /// `/T` — an optional human-readable title for the element.
348    pub title: Option<String>,
349    /// `/Lang` — a BCP 47 language tag scoping this subtree (e.g. `"en-US"`).
350    pub lang: Option<String>,
351    /// `/Alt` — an alternate textual description (accessibility), e.g. for a
352    /// [`StructRole::Figure`].
353    pub alt: Option<String>,
354    /// `/ActualText` — the exact text this element stands in for (e.g. a ligature
355    /// or an image of text).
356    pub actual_text: Option<String>,
357    /// `/E` — the expansion of an abbreviation or acronym carried by this element.
358    pub expansion: Option<String>,
359    /// The element's effective page: its own `/Pg`, or the nearest ancestor's,
360    /// as a 0-based index. `None` when none is declared/resolvable.
361    pub page: Option<usize>,
362    /// The element's children (`/K`).
363    pub kids: Vec<StructKid>,
364}
365
366impl StructElem {
367    /// The accessibility text this element directly provides: `/ActualText` (the
368    /// exact replacement) if present, else `/Alt` (an alternate description).
369    pub fn accessible_text(&self) -> Option<&str> {
370        self.actual_text.as_deref().or(self.alt.as_deref())
371    }
372
373    /// Nested structure-element children (skipping marked-content / object kids).
374    pub fn child_elements(&self) -> impl Iterator<Item = &StructElem> {
375        self.kids.iter().filter_map(|k| match k {
376            StructKid::Element(e) => Some(e),
377            _ => None,
378        })
379    }
380}
381
382/// The document's logical structure tree (`/StructTreeRoot`). Present only when
383/// the catalog declares one.
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct StructTree {
386    /// The top-level structure elements (the root's `/K`).
387    pub children: Vec<StructElem>,
388    /// `/MarkInfo /Marked true` — the document declares Tagged-PDF conformance.
389    pub marked: bool,
390}
391
392impl StructTree {
393    /// Total number of structure elements in the tree (all nesting levels).
394    pub fn element_count(&self) -> usize {
395        fn count(e: &StructElem) -> usize {
396            1 + e.child_elements().map(count).sum::<usize>()
397        }
398        self.children.iter().map(count).sum()
399    }
400}
401
402/// Whether the document declares Tagged-PDF conformance via the catalog's
403/// `/MarkInfo` dictionary (`/Marked true`). Independent of whether a
404/// `/StructTreeRoot` is actually present.
405pub fn is_tagged(file: &PdfFile) -> bool {
406    let Some(root) = catalog_dict(file) else {
407        return false;
408    };
409    let Some(mark_info) = resolve_dict(file, root.get("MarkInfo")) else {
410        return false;
411    };
412    matches!(mark_info.get("Marked"), Some(PdfObject::Bool(true)))
413}
414
415/// Parse the document's logical structure tree from the catalog's
416/// `/StructTreeRoot`. Returns `None` when the document declares no structure
417/// tree (i.e. is not tagged in the structural sense).
418pub fn parse_struct_tree(file: &PdfFile, catalog: &Catalog) -> Option<StructTree> {
419    let root = catalog_dict(file)?;
420    let tree_root = resolve_dict(file, root.get("StructTreeRoot"))?;
421
422    let mut visited = HashSet::new();
423    // Seed the cycle guard with the tree-root reference, so a kid pointing back
424    // at the root cannot spawn a spurious element (parity with outline/page-label
425    // tree-root seeding).
426    if let Some(PdfObject::Ref(id)) = root.get("StructTreeRoot") {
427        visited.insert(*id);
428    }
429
430    let mut walk = StructWalk {
431        file,
432        catalog,
433        role_map: read_role_map(file, &tree_root),
434        visited,
435        budget: MAX_STRUCT_ELEMENTS,
436    };
437
438    // The root's /K holds the top-level structure element(s). Its own /Pg (rare)
439    // seeds page inheritance.
440    let root_page = walk.page_of(&tree_root);
441    let mut children = Vec::new();
442    for kid in normalize_kids(file, &tree_root) {
443        if let Some(StructKid::Element(e)) = walk.parse_kid(&kid, root_page, 0) {
444            children.push(e);
445        }
446        // A bare MCID / OBJR directly under the root is non-conformant; drop it.
447    }
448
449    Some(StructTree {
450        children,
451        marked: is_tagged(file),
452    })
453}
454
455/// Shared state for one structure-tree traversal.
456struct StructWalk<'a> {
457    file: &'a PdfFile,
458    catalog: &'a Catalog,
459    /// `/RoleMap`: producer type name → mapped-to type name.
460    role_map: HashMap<String, String>,
461    /// Structure-element references seen so far — a `/K` back-edge to any of them
462    /// terminates that branch (cycle / shared-subtree guard).
463    visited: HashSet<ObjectId>,
464    /// Remaining element + kid-entry budget (shared across the whole walk).
465    budget: usize,
466}
467
468impl StructWalk<'_> {
469    /// Parse one `/K` entry into a [`StructKid`]. Spends one unit of the shared
470    /// budget per entry examined (so a giant `/K` array can't be scanned for
471    /// free), and bounds element recursion by depth and the visited set.
472    fn parse_kid(
473        &mut self,
474        obj: &PdfObject,
475        parent_page: Option<usize>,
476        depth: usize,
477    ) -> Option<StructKid> {
478        if self.budget == 0 {
479            return None;
480        }
481        self.budget -= 1;
482
483        match obj {
484            // A bare integer kid is an MCID into the parent's effective page.
485            PdfObject::Integer(mcid) => Some(StructKid::MarkedContent {
486                page: parent_page,
487                mcid: *mcid,
488            }),
489
490            // A reference: to a nested structure element, or (rarely) to an
491            // MCR/OBJR dict.
492            PdfObject::Ref(id) => {
493                let resolved = self.file.resolve(*id).ok()?;
494                let dict = resolved.as_dict().ok()?;
495                match kid_dict_kind(dict) {
496                    KidKind::Mcr => self.marked_content(dict, parent_page),
497                    KidKind::Objr => self.object_ref(dict, parent_page),
498                    KidKind::Element => {
499                        // Cycle / shared-subtree guard on element identity.
500                        if !self.visited.insert(*id) {
501                            return None;
502                        }
503                        self.element(dict, parent_page, depth)
504                            .map(StructKid::Element)
505                    }
506                }
507            }
508
509            // An inline dict kid: an MCR, an OBJR, or a nested element.
510            PdfObject::Dict(dict) => match kid_dict_kind(dict) {
511                KidKind::Mcr => self.marked_content(dict, parent_page),
512                KidKind::Objr => self.object_ref(dict, parent_page),
513                KidKind::Element => self
514                    .element(dict, parent_page, depth)
515                    .map(StructKid::Element),
516            },
517
518            _ => None,
519        }
520    }
521
522    /// Build a [`StructElem`] from its dictionary, recursing into `/K`.
523    fn element(
524        &mut self,
525        dict: &PdfDict,
526        parent_page: Option<usize>,
527        depth: usize,
528    ) -> Option<StructElem> {
529        if depth > MAX_STRUCT_DEPTH {
530            return None;
531        }
532        // Effective page: this element's /Pg, else inherited from the ancestor.
533        let page = self.page_of(dict).or(parent_page);
534
535        let raw_type = self.file_name(dict, "S").unwrap_or_default();
536        let role = StructRole::from_name(&self.resolve_role(&raw_type));
537
538        let kids = normalize_kids(self.file, dict)
539            .iter()
540            .filter_map(|k| self.parse_kid(k, page, depth + 1))
541            .collect();
542
543        Some(StructElem {
544            role,
545            raw_type,
546            title: capped_text(self.file, dict, "T"),
547            lang: capped_text(self.file, dict, "Lang"),
548            alt: capped_text(self.file, dict, "Alt"),
549            actual_text: capped_text(self.file, dict, "ActualText"),
550            expansion: capped_text(self.file, dict, "E"),
551            page,
552            kids,
553        })
554    }
555
556    /// Build a [`StructKid::MarkedContent`] from a `/MCR` dict (or a kid dict
557    /// treated as one). An MCR may carry its own `/Pg`, overriding the inherited
558    /// page; its `/MCID` is required.
559    fn marked_content(&self, dict: &PdfDict, parent_page: Option<usize>) -> Option<StructKid> {
560        let mcid = int_value(dict.get("MCID"))?;
561        let page = self.page_of(dict).or(parent_page);
562        Some(StructKid::MarkedContent { page, mcid })
563    }
564
565    /// Build a [`StructKid::Object`] from an `/OBJR` dict. `/Obj` (the referenced
566    /// object) is required; `/Pg` overrides the inherited page.
567    fn object_ref(&self, dict: &PdfDict, parent_page: Option<usize>) -> Option<StructKid> {
568        let obj = dict.get_ref("Obj").ok()?;
569        let page = self.page_of(dict).or(parent_page);
570        Some(StructKid::Object { page, obj })
571    }
572
573    /// Resolve a dict's `/Pg` (a page reference) to a 0-based page index.
574    fn page_of(&self, dict: &PdfDict) -> Option<usize> {
575        let pg = dict.get_ref("Pg").ok()?;
576        self.catalog.page_index_of(pg)
577    }
578
579    /// Read a Name-valued entry, following one indirect reference.
580    fn file_name(&self, dict: &PdfDict, key: &str) -> Option<String> {
581        crate::obj_util::name_value(self.file, dict, key)
582    }
583
584    /// Resolve a structure type through `/RoleMap`, transitively, until it maps
585    /// to a name not further mapped (or a cycle / the depth cap stops it).
586    fn resolve_role(&self, raw: &str) -> String {
587        let mut current = raw.to_string();
588        let mut seen = HashSet::new();
589        for _ in 0..MAX_ROLE_MAP_DEPTH {
590            if !seen.insert(current.clone()) {
591                break;
592            }
593            match self.role_map.get(&current) {
594                Some(next) if next != &current => current = next.clone(),
595                _ => break,
596            }
597        }
598        current
599    }
600}
601
602/// How a `/K` kid dictionary should be interpreted.
603enum KidKind {
604    /// `/Type /MCR` (or a kid dict carrying an `/MCID` and no `/S`).
605    Mcr,
606    /// `/Type /OBJR` (or a kid dict carrying an `/Obj` and no `/S`).
607    Objr,
608    /// A nested structure element.
609    Element,
610}
611
612/// Classify a `/K` kid dictionary. `/Type` is authoritative; absent it, an
613/// `/MCID`-only dict is treated as an MCR and an `/Obj`-only dict as an OBJR
614/// (lax producers), and everything else as a structure element.
615fn kid_dict_kind(dict: &PdfDict) -> KidKind {
616    match dict.get_name("Type") {
617        Ok("MCR") => return KidKind::Mcr,
618        Ok("OBJR") => return KidKind::Objr,
619        Ok("StructElem") => return KidKind::Element,
620        _ => {}
621    }
622    // No (recognized) /Type: infer from the keys present. A structure element is
623    // identified by /S; without it, /MCID → MCR and /Obj → OBJR.
624    if dict.get("S").is_none() {
625        if dict.get("MCID").is_some() {
626            return KidKind::Mcr;
627        }
628        if dict.get("Obj").is_some() {
629            return KidKind::Objr;
630        }
631    }
632    KidKind::Element
633}
634
635/// Normalize a dictionary's `/K` into a flat list of kid objects: a single value
636/// becomes a one-element list, an array is taken as-is, and an indirect `/K`
637/// array is resolved. A `/K` that is an indirect reference to a *single*
638/// structure element is kept as that reference (so the visited-set guard sees its
639/// object identity).
640fn normalize_kids(file: &PdfFile, dict: &PdfDict) -> Vec<PdfObject> {
641    match dict.get("K") {
642        Some(PdfObject::Array(a)) => a.clone(),
643        Some(PdfObject::Ref(r)) => match file.resolve(*r) {
644            Ok(PdfObject::Array(a)) => a,
645            // A ref to a non-array (a single element/MCR/OBJR): keep the ref.
646            Ok(_) => vec![PdfObject::Ref(*r)],
647            Err(_) => Vec::new(),
648        },
649        Some(other) => vec![other.clone()],
650        None => Vec::new(),
651    }
652}
653
654/// Read `/RoleMap` (producer type name → standard type name) into a map, bounded
655/// in size. Only Name → Name entries are kept.
656fn read_role_map(file: &PdfFile, tree_root: &PdfDict) -> HashMap<String, String> {
657    let mut map = HashMap::new();
658    let Some(rm) = resolve_dict(file, tree_root.get("RoleMap")) else {
659        return map;
660    };
661    for (key, value) in rm.0.iter() {
662        if map.len() >= MAX_ROLE_MAP_ENTRIES {
663            break;
664        }
665        if let PdfObject::Name(n) = value {
666            map.insert(key.as_str().to_string(), n.as_str().to_string());
667        }
668    }
669    map
670}
671
672/// An integer object (a bare `/K` MCID, or `/MCID`), accepting a whole-valued
673/// real for lax producers.
674fn int_value(obj: Option<&PdfObject>) -> Option<i64> {
675    match obj? {
676        PdfObject::Integer(n) => Some(*n),
677        PdfObject::Real(f) if f.is_finite() && f.fract() == 0.0 => Some(*f as i64),
678        _ => None,
679    }
680}
681
682/// Read a text-string entry and cap it to [`MAX_TEXT_CHARS`] on a `char`
683/// boundary (an adversarial `/Alt`/`/ActualText` can be arbitrarily long).
684fn capped_text(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
685    match text(file, dict, key) {
686        Some(s) if s.chars().count() > MAX_TEXT_CHARS => {
687            Some(s.chars().take(MAX_TEXT_CHARS).collect())
688        }
689        other => other,
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::test_util::build_pdf;
697    use crate::PdfDocument;
698
699    const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
700    const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
701
702    fn open(objects: &[&str]) -> PdfDocument {
703        PdfDocument::open(build_pdf(objects)).expect("open pdf")
704    }
705
706    /// Build a document whose catalog (object 1) is `catalog`, with the standard
707    /// one-page tree, followed by `extra` structure objects (objects 4, 5, …).
708    fn doc(catalog: &str, extra: &[&str]) -> PdfDocument {
709        let mut objs = vec![catalog, PAGES, PAGE];
710        objs.extend_from_slice(extra);
711        open(&objs)
712    }
713
714    #[test]
715    fn no_struct_tree_is_none() {
716        let d = doc("<< /Type /Catalog /Pages 2 0 R >>", &[]);
717        assert!(d.struct_tree().is_none());
718        assert!(!d.is_tagged());
719    }
720
721    #[test]
722    fn mark_info_marks_tagged() {
723        let d = doc(
724            "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> >>",
725            &[],
726        );
727        assert!(d.is_tagged());
728        // MarkInfo without a StructTreeRoot still has no tree.
729        assert!(d.struct_tree().is_none());
730    }
731
732    #[test]
733    fn simple_document_paragraph_with_mcids() {
734        // StructTreeRoot(4) -> Document(5) -> P(6) with two MCID kids, /Pg = page 0.
735        let d = doc(
736            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R \
737             /MarkInfo << /Marked true >> >>",
738            &[
739                "<< /Type /StructTreeRoot /K 5 0 R >>",
740                "<< /Type /StructElem /S /Document /P 4 0 R /K 6 0 R >>",
741                "<< /Type /StructElem /S /P /P 5 0 R /Pg 3 0 R /K [0 1] >>",
742            ],
743        );
744        let tree = d.struct_tree().expect("tree");
745        assert!(tree.marked);
746        assert_eq!(tree.children.len(), 1);
747        let document = &tree.children[0];
748        assert_eq!(document.role, StructRole::Document);
749        assert_eq!(document.kids.len(), 1);
750
751        let para = document.child_elements().next().unwrap();
752        assert_eq!(para.role, StructRole::P);
753        assert_eq!(para.page, Some(0));
754        assert_eq!(
755            para.kids,
756            vec![
757                StructKid::MarkedContent {
758                    page: Some(0),
759                    mcid: 0
760                },
761                StructKid::MarkedContent {
762                    page: Some(0),
763                    mcid: 1
764                },
765            ]
766        );
767        assert_eq!(tree.element_count(), 2);
768    }
769
770    #[test]
771    fn role_map_resolves_custom_type() {
772        // A producer type "Heading1" mapped onto the standard /H1 by /RoleMap.
773        let d = doc(
774            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
775            &[
776                "<< /Type /StructTreeRoot /K 5 0 R /RoleMap << /Heading1 /H1 >> >>",
777                "<< /Type /StructElem /S /Heading1 /P 4 0 R >>",
778            ],
779        );
780        let tree = d.struct_tree().expect("tree");
781        let h = &tree.children[0];
782        assert_eq!(h.role, StructRole::H1);
783        assert!(h.role.is_heading());
784        assert_eq!(h.raw_type, "Heading1"); // original /S preserved
785    }
786
787    #[test]
788    fn unmapped_custom_type_is_other() {
789        let d = doc(
790            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
791            &[
792                "<< /Type /StructTreeRoot /K 5 0 R >>",
793                "<< /Type /StructElem /S /MyWidget /P 4 0 R >>",
794            ],
795        );
796        let role = &d.struct_tree().unwrap().children[0].role;
797        assert_eq!(role, &StructRole::Other("MyWidget".to_string()));
798        assert!(!role.is_standard());
799        assert_eq!(role.as_str(), "MyWidget");
800    }
801
802    #[test]
803    fn figure_alt_text_is_accessible() {
804        let d = doc(
805            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
806            &[
807                "<< /Type /StructTreeRoot /K 5 0 R >>",
808                "<< /Type /StructElem /S /Figure /P 4 0 R /Alt (A bar chart) >>",
809            ],
810        );
811        let fig = &d.struct_tree().unwrap().children[0];
812        assert_eq!(fig.role, StructRole::Figure);
813        assert_eq!(fig.alt.as_deref(), Some("A bar chart"));
814        assert_eq!(fig.accessible_text(), Some("A bar chart"));
815    }
816
817    #[test]
818    fn actual_text_preferred_over_alt() {
819        let d = doc(
820            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
821            &[
822                "<< /Type /StructTreeRoot /K 5 0 R >>",
823                "<< /Type /StructElem /S /Span /P 4 0 R /Alt (alt) /ActualText (exact) >>",
824            ],
825        );
826        let span = &d.struct_tree().unwrap().children[0];
827        assert_eq!(span.accessible_text(), Some("exact"));
828    }
829
830    #[test]
831    fn objr_kid_resolves_object_and_page() {
832        // A kid /OBJR referencing an annotation object on page 0.
833        let d = doc(
834            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
835            &[
836                "<< /Type /StructTreeRoot /K 5 0 R >>",
837                "<< /Type /StructElem /S /Link /P 4 0 R \
838                 /K << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >> >>",
839                "<< /Type /Annot /Subtype /Link >>",
840            ],
841        );
842        let link = &d.struct_tree().unwrap().children[0];
843        assert_eq!(link.role, StructRole::Link);
844        assert_eq!(link.kids.len(), 1);
845        match &link.kids[0] {
846            StructKid::Object { page, obj } => {
847                assert_eq!(*page, Some(0));
848                assert_eq!(obj.0, 6); // object number 6
849            }
850            other => panic!("expected OBJR kid, got {other:?}"),
851        }
852    }
853
854    #[test]
855    fn mcr_dict_kid_with_explicit_page() {
856        let d = doc(
857            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
858            &[
859                "<< /Type /StructTreeRoot /K 5 0 R >>",
860                "<< /Type /StructElem /S /P /P 4 0 R \
861                 /K << /Type /MCR /Pg 3 0 R /MCID 7 >> >>",
862            ],
863        );
864        let para = &d.struct_tree().unwrap().children[0];
865        assert_eq!(
866            para.kids[0],
867            StructKid::MarkedContent {
868                page: Some(0),
869                mcid: 7
870            }
871        );
872    }
873
874    #[test]
875    fn page_inherited_from_ancestor() {
876        // The inner Span has no /Pg; it inherits the Sect's /Pg for its MCID kid.
877        let d = doc(
878            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
879            &[
880                "<< /Type /StructTreeRoot /K 5 0 R >>",
881                "<< /Type /StructElem /S /Sect /P 4 0 R /Pg 3 0 R /K 6 0 R >>",
882                "<< /Type /StructElem /S /Span /P 5 0 R /K [9] >>",
883            ],
884        );
885        let span = d.struct_tree().unwrap().children[0]
886            .child_elements()
887            .next()
888            .unwrap()
889            .clone();
890        assert_eq!(span.page, Some(0), "inherited /Pg");
891        assert_eq!(
892            span.kids[0],
893            StructKid::MarkedContent {
894                page: Some(0),
895                mcid: 9
896            }
897        );
898    }
899
900    #[test]
901    fn single_ref_k_not_array() {
902        // /K as a single reference (not an array) is honoured.
903        let d = doc(
904            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
905            &[
906                "<< /Type /StructTreeRoot /K 5 0 R >>",
907                "<< /Type /StructElem /S /Document /K 6 0 R >>",
908                "<< /Type /StructElem /S /P /P 5 0 R >>",
909            ],
910        );
911        let document = &d.struct_tree().unwrap().children[0];
912        assert_eq!(document.child_elements().count(), 1);
913        assert_eq!(
914            document.child_elements().next().unwrap().role,
915            StructRole::P
916        );
917    }
918
919    #[test]
920    fn cyclic_kids_terminate() {
921        // Element 5 /K -> 6, element 6 /K -> 5: the visited guard stops the loop.
922        let d = doc(
923            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
924            &[
925                "<< /Type /StructTreeRoot /K 5 0 R >>",
926                "<< /Type /StructElem /S /Document /K 6 0 R >>",
927                "<< /Type /StructElem /S /Sect /K 5 0 R >>",
928            ],
929        );
930        let tree = d.struct_tree().expect("tree (no hang)");
931        // Document -> Sect, and Sect's back-edge to Document is cut.
932        assert_eq!(tree.children.len(), 1);
933        let sect = tree.children[0].child_elements().next().unwrap();
934        assert_eq!(sect.role, StructRole::Sect);
935        assert_eq!(sect.child_elements().count(), 0);
936    }
937
938    #[test]
939    fn root_back_edge_makes_no_spurious_element() {
940        // The top element's only kid points back at the StructTreeRoot ref, which
941        // is pre-seeded into the visited set.
942        let d = doc(
943            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
944            &[
945                "<< /Type /StructTreeRoot /K 5 0 R >>",
946                "<< /Type /StructElem /S /Document /K 4 0 R >>",
947            ],
948        );
949        let document = &d.struct_tree().unwrap().children[0];
950        assert_eq!(document.role, StructRole::Document);
951        assert_eq!(document.child_elements().count(), 0, "root back-edge cut");
952    }
953
954    #[test]
955    fn role_map_cycle_terminates() {
956        // /RoleMap maps Foo -> Bar -> Foo; resolution must not loop, and the type
957        // stays non-standard (Other).
958        let d = doc(
959            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
960            &[
961                "<< /Type /StructTreeRoot /K 5 0 R /RoleMap << /Foo /Bar /Bar /Foo >> >>",
962                "<< /Type /StructElem /S /Foo /P 4 0 R >>",
963            ],
964        );
965        let role = &d.struct_tree().expect("tree (no hang)").children[0].role;
966        assert!(!role.is_standard());
967    }
968
969    #[test]
970    fn deeply_nested_tree_terminates() {
971        // A chain of elements deeper than MAX_STRUCT_DEPTH must terminate without
972        // a stack overflow; the over-deep tail is pruned.
973        let depth = MAX_STRUCT_DEPTH + 50;
974        let mut objs = vec![
975            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>".to_string(),
976            PAGES.to_string(),
977            PAGE.to_string(),
978            "<< /Type /StructTreeRoot /K 5 0 R >>".to_string(),
979        ];
980        // Objects 5..(5+depth): each /K points at the next; the last has none.
981        for i in 0..depth {
982            let obj_num = 5 + i;
983            if i + 1 < depth {
984                objs.push(format!(
985                    "<< /Type /StructElem /S /Div /K {} 0 R >>",
986                    obj_num + 1
987                ));
988            } else {
989                objs.push("<< /Type /StructElem /S /Div >>".to_string());
990            }
991        }
992        let refs: Vec<&str> = objs.iter().map(|s| s.as_str()).collect();
993        let d = open(&refs);
994        // No panic / no hang; the tree is built up to the depth cap.
995        assert!(d.struct_tree().is_some());
996    }
997
998    #[test]
999    fn huge_alt_text_is_capped() {
1000        let big = "A".repeat(MAX_TEXT_CHARS + 1000);
1001        let d = doc(
1002            "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 4 0 R >>",
1003            &[
1004                "<< /Type /StructTreeRoot /K 5 0 R >>",
1005                &format!("<< /Type /StructElem /S /Figure /Alt ({big}) >>"),
1006            ],
1007        );
1008        let alt = d.struct_tree().unwrap().children[0].alt.clone().unwrap();
1009        assert_eq!(alt.chars().count(), MAX_TEXT_CHARS);
1010    }
1011
1012    #[test]
1013    fn role_name_round_trip() {
1014        for name in [
1015            "Document", "TOC", "TOCI", "P", "H1", "H6", "L", "LI", "Lbl", "LBody", "Table", "TR",
1016            "TH", "TD", "THead", "TBody", "TFoot", "Span", "BibEntry", "Link", "RB", "WP",
1017            "Figure", "Formula", "Form",
1018        ] {
1019            let role = StructRole::from_name(name);
1020            assert!(role.is_standard(), "{name} should be standard");
1021            assert_eq!(role.as_str(), name, "round-trip for {name}");
1022        }
1023    }
1024}