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}