Skip to main content

xberg_native_pdf/structure/
types.rs

1//! Types for PDF logical structure trees.
2//!
3//! Implements structure element types according to ISO 32000-1:2008 Section 14.7.2.
4
5use crate::object::{Object, ObjectRef};
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8
9/// Source of a Marked Content Identifier (MCID).
10///
11/// Per ISO 32000-1:2008 §14.7.4.3, MCIDs are NOT page-global. Each
12/// content stream that can carry `/StructParents` defines its own MCID
13/// namespace:
14///
15/// - A **page** content stream (`Page::StructParents`).
16/// - A **Form XObject** content stream (`/Subtype /Form`,
17///   `Form::StructParents`).
18/// - A **Tiling Pattern** content stream (`/PatternType 1`,
19///   `Pattern::StructParents`).
20///
21/// Two Form XObjects rendered on the same page may both emit MCID `0`
22/// inside their respective streams; they refer to different structure
23/// elements and must NOT collapse onto a single key. The same applies
24/// to two Tiling Patterns. This enum is the namespace-discriminator that
25/// keeps them apart.
26///
27/// `ActualTextIndex` and `TextSpan::mcid_scope` use this enum as the
28/// scope half of their `(scope, mcid)` lookup key.
29#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
30pub enum McidScope {
31    /// MCID drawn directly by the page content stream. The `u32` is the
32    /// 0-based page index.
33    Page(u32),
34    /// MCID drawn by a Form XObject content stream. The `ObjectRef`
35    /// identifies the form (so two distinct forms on one page stay
36    /// distinct).
37    Form(ObjectRef),
38    /// MCID drawn by a Tiling Pattern content stream. The `ObjectRef`
39    /// identifies the pattern (so two distinct patterns on one page
40    /// stay distinct).
41    Pattern(ObjectRef),
42}
43
44impl McidScope {
45    /// Returns the page index when the scope is `Page(p)`, otherwise
46    /// `None`. Useful for assemblers that want a fast-path on the
47    /// common case.
48    pub fn page(&self) -> Option<u32> {
49        match self {
50            McidScope::Page(p) => Some(*p),
51            _ => None,
52        }
53    }
54}
55
56/// The root of a PDF structure tree (StructTreeRoot dictionary).
57///
58/// This is the entry point for accessing a document's logical structure.
59/// According to PDF spec Section 14.7.2, the StructTreeRoot contains:
60/// - `/Type` - Must be `/StructTreeRoot`
61/// - `/K` - The immediate child or children of the structure tree root
62/// - `/ParentTree` - Maps marked content to structure elements
63/// - `/RoleMap` - Maps non-standard structure types to standard ones
64#[derive(Debug, Clone)]
65pub struct StructTreeRoot {
66    /// Root structure element(s)
67    pub root_elements: Vec<StructElem>,
68
69    /// Parent tree mapping MCIDs to structure elements (optional)
70    pub parent_tree: Option<ParentTree>,
71
72    /// Role map for custom structure types (optional)
73    pub role_map: HashMap<String, String>,
74}
75
76impl StructTreeRoot {
77    /// Create a new structure tree root
78    pub fn new() -> Self {
79        Self {
80            root_elements: Vec::new(),
81            parent_tree: None,
82            role_map: HashMap::new(),
83        }
84    }
85
86    /// Add a root element to the structure tree
87    pub fn add_root_element(&mut self, elem: StructElem) {
88        self.root_elements.push(elem);
89    }
90}
91
92impl Default for StructTreeRoot {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// A structure element (StructElem) in the structure tree.
99///
100/// According to PDF spec Section 14.7.2, each StructElem has:
101/// - `/S` - Structure type (e.g., /Document, /P, /H1, /Sect)
102/// - `/K` - Children (structure elements or marked content references)
103/// - `/P` - Parent structure element
104/// - `/Pg` - Page containing this element (optional)
105/// - `/A` - Attributes (optional)
106/// - `/Alt` - Alternate description (optional, per Section 14.9.3)
107/// - `/E` - Expansion for abbreviations (optional, per Section 14.9.5)
108#[derive(Debug, Clone)]
109pub struct StructElem {
110    /// Structure type (e.g., "Document", "P", "H1", "Sect")
111    pub struct_type: StructType,
112
113    /// Child elements (structure elements or content references)
114    pub children: Vec<StructChild>,
115
116    /// Page number this element appears on (if known)
117    pub page: Option<u32>,
118
119    /// Attributes (optional)
120    pub attributes: HashMap<String, Object>,
121
122    /// Alternate description for accessibility (optional)
123    /// Per PDF spec Section 14.9.3, this provides a human-readable
124    /// description of the element's content (e.g., formula LaTeX or description)
125    pub alt_text: Option<String>,
126
127    /// Expansion for abbreviations (optional)
128    /// Per PDF spec Section 14.9.5, the /E entry provides the expanded form
129    /// of an abbreviation or acronym (e.g., "PDF" -> "Portable Document Format")
130    pub expansion: Option<String>,
131
132    /// Actual text replacement (optional)
133    /// Per PDF spec Section 14.9.4, /ActualText provides exact replacement text
134    /// for structure elements, overriding all descendant content.
135    pub actual_text: Option<String>,
136
137    /// Original role name before RoleMap resolution (optional).
138    /// When a custom structure type (e.g., "Heading1") is mapped to a standard
139    /// type (e.g., "H1") via the RoleMap, this field preserves the original name.
140    /// None when the element's /S value is already a standard type.
141    pub source_role: Option<String>,
142}
143
144impl StructElem {
145    /// Create a new structure element
146    pub fn new(struct_type: StructType) -> Self {
147        Self {
148            struct_type,
149            children: Vec::new(),
150            page: None,
151            attributes: HashMap::new(),
152            alt_text: None,
153            expansion: None,
154            actual_text: None,
155            source_role: None,
156        }
157    }
158
159    /// Add a child to this structure element
160    pub fn add_child(&mut self, child: StructChild) {
161        self.children.push(child);
162    }
163}
164
165/// Child of a structure element (either another struct elem or marked content reference)
166#[derive(Debug, Clone)]
167pub enum StructChild {
168    /// Another structure element (recursive hierarchy)
169    StructElem(Box<StructElem>),
170
171    /// Reference to marked content by MCID (Marked Content ID)
172    MarkedContentRef {
173        /// Marked Content ID
174        mcid: u32,
175        /// Page number containing this marked content
176        page: u32,
177        /// Content-stream scope this MCID belongs to (ISO 32000-1:2008
178        /// §14.7.4.3). `Page(_)` for MCIDs drawn directly by the page
179        /// content stream (the default when an MCR omits `/Stm`).
180        /// `Form(_)` / `Pattern(_)` when the MCR's `/Stm` resolves to
181        /// a Form XObject or Tiling Pattern stream.
182        scope: McidScope,
183    },
184
185    /// Object reference (indirect reference to another StructElem)
186    ObjectRef(u32, u16),
187}
188
189/// Standard structure types from PDF spec Section 14.8.4.
190///
191/// These are the standard structure types defined by the PDF specification.
192/// Custom types can be mapped to standard types via the RoleMap.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum StructType {
195    /// Document root
196    Document,
197    /// Part (major division)
198    Part,
199    /// Article
200    Art,
201    /// Section
202    Sect,
203    /// Division
204    Div,
205
206    /// Paragraph
207    P,
208    /// Heading level 1-6
209    H,
210    /// Heading level 1
211    H1,
212    /// Heading level 2
213    H2,
214    /// Heading level 3
215    H3,
216    /// Heading level 4
217    H4,
218    /// Heading level 5
219    H5,
220    /// Heading level 6
221    H6,
222
223    /// List
224    L,
225    /// List item
226    LI,
227    /// Label (list item marker)
228    Lbl,
229    /// List body (list item content)
230    LBody,
231
232    /// Table
233    Table,
234    /// Table row
235    TR,
236    /// Table header cell
237    TH,
238    /// Table data cell
239    TD,
240    /// Table header group
241    THead,
242    /// Table body group
243    TBody,
244    /// Table footer group
245    TFoot,
246
247    /// Span (inline generic)
248    Span,
249    /// Quote
250    Quote,
251    /// Note
252    Note,
253    /// Reference
254    Reference,
255    /// Bibliographic entry
256    BibEntry,
257    /// Code
258    Code,
259    /// Link
260    Link,
261    /// Annotation
262    Annot,
263    /// Word break - explicit word boundary in structure tree (PDF 1.5+)
264    ///
265    /// Per PDF spec Section 14.8.4.4, WB elements mark word boundaries
266    /// in languages that do not use spaces between words (e.g., CJK).
267    WB,
268
269    /// Figure
270    Figure,
271    /// Formula
272    Formula,
273    /// Form (input field)
274    Form,
275
276    /// Custom structure type not defined in the PDF specification
277    Custom(String),
278}
279
280impl StructType {
281    /// Parse structure type from string (e.g., "/P" -> StructType::P)
282    pub fn from_str(s: &str) -> Self {
283        match s {
284            "Document" => Self::Document,
285            "Part" => Self::Part,
286            "Art" => Self::Art,
287            "Sect" => Self::Sect,
288            "Div" => Self::Div,
289            "P" => Self::P,
290            "H" => Self::H,
291            "H1" => Self::H1,
292            "H2" => Self::H2,
293            "H3" => Self::H3,
294            "H4" => Self::H4,
295            "H5" => Self::H5,
296            "H6" => Self::H6,
297            "L" => Self::L,
298            "LI" => Self::LI,
299            "Lbl" => Self::Lbl,
300            "LBody" => Self::LBody,
301            "Table" => Self::Table,
302            "TR" => Self::TR,
303            "TH" => Self::TH,
304            "TD" => Self::TD,
305            "THead" => Self::THead,
306            "TBody" => Self::TBody,
307            "TFoot" => Self::TFoot,
308            "Span" => Self::Span,
309            "Quote" => Self::Quote,
310            "Note" => Self::Note,
311            "Reference" => Self::Reference,
312            "BibEntry" => Self::BibEntry,
313            "Code" => Self::Code,
314            "Link" => Self::Link,
315            "Annot" => Self::Annot,
316            "WB" => Self::WB,
317            "Figure" => Self::Figure,
318            "Formula" => Self::Formula,
319            "Form" => Self::Form,
320            _ => Self::Custom(s.to_string()),
321        }
322    }
323
324    /// Check if this is a heading type (H, H1-H6)
325    pub fn is_heading(&self) -> bool {
326        matches!(
327            self,
328            Self::H | Self::H1 | Self::H2 | Self::H3 | Self::H4 | Self::H5 | Self::H6
329        )
330    }
331
332    /// Check if this is a block-level element
333    pub fn is_block(&self) -> bool {
334        matches!(
335            self,
336            Self::Document
337                | Self::Part
338                | Self::Art
339                | Self::Sect
340                | Self::Div
341                | Self::P
342                | Self::H
343                | Self::H1
344                | Self::H2
345                | Self::H3
346                | Self::H4
347                | Self::H5
348                | Self::H6
349                | Self::Table
350                | Self::Figure
351                | Self::Formula
352        )
353    }
354
355    /// Get heading level (1-6) if this is a heading type
356    pub fn heading_level(&self) -> Option<u8> {
357        match self {
358            Self::H | Self::H1 => Some(1),
359            Self::H2 => Some(2),
360            Self::H3 => Some(3),
361            Self::H4 => Some(4),
362            Self::H5 => Some(5),
363            Self::H6 => Some(6),
364            _ => None,
365        }
366    }
367
368    /// Check if this is a list type (L, LI, Lbl, LBody)
369    pub fn is_list(&self) -> bool {
370        matches!(self, Self::L | Self::LI | Self::Lbl | Self::LBody)
371    }
372
373    /// Check if this is a word break element (WB)
374    ///
375    /// Word break elements mark explicit word boundaries in languages
376    /// that don't use spaces (e.g., CJK). When encountered during text
377    /// extraction, a space should be inserted.
378    pub fn is_word_break(&self) -> bool {
379        matches!(self, Self::WB)
380    }
381
382    /// Get markdown prefix for this structure type
383    pub fn markdown_prefix(&self) -> Option<&'static str> {
384        match self {
385            Self::H1 => Some("# "),
386            Self::H2 => Some("## "),
387            Self::H3 => Some("### "),
388            Self::H4 => Some("#### "),
389            Self::H5 => Some("##### "),
390            Self::H6 => Some("###### "),
391            Self::Lbl => Some("- "),
392            _ => None,
393        }
394    }
395}
396
397/// Parent tree that maps marked content IDs to structure elements.
398///
399/// According to PDF spec Section 14.7.4.4, the parent tree is a number tree
400/// that maps MCID values to the structure elements that own them.
401#[derive(Debug, Clone)]
402pub struct ParentTree {
403    /// Mapping from page number to MCID mappings for that page
404    pub page_mappings: HashMap<u32, HashMap<u32, ParentTreeEntry>>,
405}
406
407impl ParentTree {
408    /// Create a new parent tree
409    pub fn new() -> Self {
410        Self {
411            page_mappings: HashMap::new(),
412        }
413    }
414
415    /// Get the structure element that owns the given MCID on the given page
416    pub fn get_parent(&self, page: u32, mcid: u32) -> Option<&ParentTreeEntry> {
417        self.page_mappings.get(&page).and_then(|page_map| page_map.get(&mcid))
418    }
419}
420
421impl Default for ParentTree {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427/// Entry in the parent tree
428#[derive(Debug, Clone)]
429pub enum ParentTreeEntry {
430    /// Direct reference to a structure element
431    StructElem(Box<StructElem>),
432    /// Object reference (indirect)
433    ObjectRef(u32, u16),
434}
435
436/// MarkInfo dictionary from the document catalog.
437///
438/// Per ISO 32000-1:2008 Section 14.7.1, the MarkInfo dictionary contains:
439/// - `/Marked` - Whether the document conforms to Tagged PDF conventions
440/// - `/Suspects` - Whether the document contains suspect content that
441///   may not render properly or has questionable structure
442/// - `/UserProperties` - Whether the document contains user properties
443///
444/// When `suspects` is true, reading order strategies should consider
445/// falling back to geometric ordering instead of relying on the
446/// potentially unreliable structure tree.
447#[derive(Debug, Clone, Default)]
448pub struct MarkInfo {
449    /// Whether the document is marked (conforms to Tagged PDF conventions)
450    pub marked: bool,
451
452    /// Whether the document contains suspect content
453    ///
454    /// Per PDF spec Section 14.9.2, when this is true, the structure tree
455    /// may contain errors or unreliable content. Reading order strategies
456    /// should consider falling back to geometric ordering.
457    pub suspects: bool,
458
459    /// Whether the document uses user-defined properties
460    pub user_properties: bool,
461}
462
463impl MarkInfo {
464    /// Create a new MarkInfo with default values (all false)
465    pub fn new() -> Self {
466        Self::default()
467    }
468
469    /// Check if the structure tree should be considered reliable
470    ///
471    /// Returns true if the document is marked as Tagged PDF AND
472    /// does not have suspected unreliable content.
473    pub fn is_structure_reliable(&self) -> bool {
474        self.marked && !self.suspects
475    }
476}
477
478/// Pre-computed index of structure-element `/ActualText` replacements
479/// resolved over the entire structure tree.
480///
481/// Per ISO 32000-1:2008 §14.9.4, a structure element may carry an
482/// `/ActualText` entry that replaces the entire content of its
483/// descendants for text-extraction purposes. The replacement scope is
484/// the subtree rooted at the bearing element. Nested ActualText
485/// declarations override their ancestors for the spans they cover.
486///
487/// ## Data model: keyed by `(McidScope, mcid)`
488///
489/// Per ISO 32000-1:2008 §14.7.4.3, MCIDs are NOT globally unique — they
490/// are unique only within a single content stream (page content stream,
491/// Form XObject content stream, or Tiling Pattern content stream). Two
492/// Form XObjects on the same page can both emit MCID `0` referring to
493/// distinct structure elements. Keying by `(McidScope, mcid)` keeps
494/// every namespace separate.
495///
496/// For page-scoped MCIDs, `McidScope::Page(page_index)` plays exactly
497/// the role the previous `(page, mcid)` keying did; the cross-page
498/// emit-once rule still applies via [`Self::suppress_only`]. Form- and
499/// Pattern-scoped MCIDs are intrinsically per-stream — every covered
500/// MCID emits at its anchor (the form/pattern's content stream covers
501/// a single namespace).
502///
503/// ## Emission model: mcid-driven, consecutive-run dedup
504///
505/// Consumers iterate per-page MCIDs in structure-tree order and emit
506/// `mcid_to_actual_text[(page, mcid)]` whenever the replacement
507/// changes between consecutive covered MCIDs. A subtree covering
508/// `[5, 6, 7]` with one replacement string therefore emits ONE
509/// replacement; a subtree where a nested ActualText overrides MCID 6
510/// gives THREE emissions (outer at 5, inner at 6, outer at 7), which
511/// is the correct inner-wins shape. Run-end is detected at the next
512/// non-covered MCID or at a covered MCID with a different replacement.
513///
514/// ## Visibility (OCG filtering)
515///
516/// When a covered MCID has been filtered out by an excluded OCG layer,
517/// the consumer skips emission for that MCID but does NOT break the
518/// consecutive-run dedup — so partial OCG visibility still emits the
519/// replacement at the first visible covered MCID in the run.
520#[derive(Debug, Clone, Default)]
521pub struct ActualTextIndex {
522    /// Map from `(McidScope, mcid)` → its innermost replacement text.
523    ///
524    /// The "innermost" resolution honours nesting: when a descendant
525    /// element redeclares `/ActualText`, the descendant's text replaces
526    /// the ancestor's for `(scope, mcid)` keys in the inner subtree.
527    pub mcid_to_actual_text: HashMap<(McidScope, u32), Arc<str>>,
528
529    /// `(McidScope, mcid)` pairs whose raw glyph spans must be
530    /// suppressed during assembly.
531    ///
532    /// Every key in [`Self::mcid_to_actual_text`] is present here, and
533    /// keys in [`Self::suppress_only`] are present here too. Suppression
534    /// prevents duplicate output: the replacement is emitted via the
535    /// mcid-driven walk, and raw glyphs for the same `(scope, mcid)`
536    /// are dropped.
537    pub covered_mcids: HashSet<(McidScope, u32)>,
538
539    /// `(McidScope, mcid)` pairs that are covered (raw glyphs
540    /// suppressed) but must NOT emit a replacement.
541    ///
542    /// For page-scoped subtrees, this encodes "emit-once on the first
543    /// page" semantics: the bearing element's first-page `(Page(p), mcid)`
544    /// entries land in [`Self::mcid_to_actual_text`]; every other page's
545    /// `(Page(p), mcid)` entries land here so the raw glyphs are still
546    /// suppressed but no second emission fires. Form- and Pattern-
547    /// scoped subtrees do not populate this set: each
548    /// `Form` / `Pattern` covers a single namespace.
549    pub suppress_only: HashSet<(McidScope, u32)>,
550}
551
552impl ActualTextIndex {
553    /// Construct an empty index.
554    pub fn new() -> Self {
555        Self::default()
556    }
557
558    /// Returns true when no ActualText scopes were discovered.
559    pub fn is_empty(&self) -> bool {
560        self.covered_mcids.is_empty()
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_struct_type_parsing() {
570        assert_eq!(StructType::from_str("P"), StructType::P);
571        assert_eq!(StructType::from_str("H1"), StructType::H1);
572        assert_eq!(StructType::from_str("Document"), StructType::Document);
573
574        match StructType::from_str("CustomType") {
575            StructType::Custom(s) => assert_eq!(s, "CustomType"),
576            _ => panic!("Expected Custom type"),
577        }
578    }
579
580    #[test]
581    fn test_is_heading() {
582        assert!(StructType::H1.is_heading());
583        assert!(StructType::H2.is_heading());
584        assert!(StructType::H.is_heading());
585        assert!(!StructType::P.is_heading());
586        assert!(!StructType::Document.is_heading());
587    }
588
589    #[test]
590    fn test_is_block() {
591        assert!(StructType::P.is_block());
592        assert!(StructType::H1.is_block());
593        assert!(StructType::Document.is_block());
594        assert!(!StructType::Span.is_block());
595        assert!(!StructType::Link.is_block());
596    }
597
598    #[test]
599    fn test_heading_level() {
600        assert_eq!(StructType::H.heading_level(), Some(1));
601        assert_eq!(StructType::H1.heading_level(), Some(1));
602        assert_eq!(StructType::H2.heading_level(), Some(2));
603        assert_eq!(StructType::H3.heading_level(), Some(3));
604        assert_eq!(StructType::H4.heading_level(), Some(4));
605        assert_eq!(StructType::H5.heading_level(), Some(5));
606        assert_eq!(StructType::H6.heading_level(), Some(6));
607        assert_eq!(StructType::P.heading_level(), None);
608        assert_eq!(StructType::Document.heading_level(), None);
609    }
610
611    #[test]
612    fn test_is_list() {
613        assert!(StructType::L.is_list());
614        assert!(StructType::LI.is_list());
615        assert!(StructType::Lbl.is_list());
616        assert!(StructType::LBody.is_list());
617        assert!(!StructType::P.is_list());
618        assert!(!StructType::H1.is_list());
619        assert!(!StructType::Table.is_list());
620    }
621
622    #[test]
623    fn test_markdown_prefix() {
624        assert_eq!(StructType::H1.markdown_prefix(), Some("# "));
625        assert_eq!(StructType::H2.markdown_prefix(), Some("## "));
626        assert_eq!(StructType::H3.markdown_prefix(), Some("### "));
627        assert_eq!(StructType::H4.markdown_prefix(), Some("#### "));
628        assert_eq!(StructType::H5.markdown_prefix(), Some("##### "));
629        assert_eq!(StructType::H6.markdown_prefix(), Some("###### "));
630        assert_eq!(StructType::Lbl.markdown_prefix(), Some("- "));
631        assert_eq!(StructType::P.markdown_prefix(), None);
632        assert_eq!(StructType::Table.markdown_prefix(), None);
633    }
634}