Skip to main content

xberg_native_pdf/elements/
mod.rs

1//! Unified content elements for read/write operations.
2//!
3//! This module provides a unified intermediate representation for PDF content
4//! that works for both reading (extraction) and writing (generation).
5//!
6//! ## Design
7//!
8//! The `ContentElement` enum represents all content types that can be:
9//! - Extracted from existing PDFs (read path)
10//! - Generated into new PDFs (write path)
11//!
12//! This symmetric design enables round-trip operations (read → transform → write)
13//! and provides a consistent API for both directions.
14//!
15//! ## Example
16//!
17//! ```ignore
18//! use xberg_native_pdf::elements::{ContentElement, TextContent, FontSpec};
19//! use xberg_native_pdf::geometry::Rect;
20//!
21//! // Create a text element (for writing)
22//! let text = TextContent { artifact_type: None,
23//!     text: "Hello, World!".to_string(),
24//!     bbox: Rect::new(72.0, 720.0, 100.0, 12.0),
25//!     font: FontSpec::default(),
26//!     style: Default::default(),
27//!     reading_order: Some(0),
28//! };
29//!
30//! let element = ContentElement::Text(text);
31//! ```
32
33mod image;
34mod path;
35mod table;
36mod text;
37
38pub use image::{ColorSpace, ImageContent, ImageFormat};
39pub(crate) use path::path_operations_bbox;
40pub use path::{LineCap, LineJoin, PathContent, PathOperation};
41pub use table::{
42    TableCellAlign, TableCellContent, TableCellVAlign, TableContent, TableContentStyle, TableDetectionInfo,
43    TableRowContent, TableSource,
44};
45pub use text::{FontSpec, FontStyle, TextContent, TextStyle};
46
47use crate::geometry::Rect;
48
49/// A content element that can be extracted from or written to a PDF.
50///
51/// This is the unified intermediate representation used by both the
52/// read pipeline (PDF → ContentElement) and write pipeline (ContentElement → PDF).
53#[derive(Debug, Clone)]
54pub enum ContentElement {
55    /// Text content with positioning and styling
56    Text(TextContent),
57    /// Image content with position and format information
58    Image(ImageContent),
59    /// Vector path/graphics content
60    Path(PathContent),
61    /// Structural element (for Tagged PDF support)
62    Structure(StructureElement),
63    /// Table content with rows and cells
64    Table(TableContent),
65}
66
67impl ContentElement {
68    /// Get the bounding box of this element.
69    pub fn bbox(&self) -> Rect {
70        match self {
71            ContentElement::Text(t) => t.bbox,
72            ContentElement::Image(i) => i.bbox,
73            ContentElement::Path(p) => p.bbox,
74            ContentElement::Structure(s) => s.bbox,
75            ContentElement::Table(t) => t.bbox,
76        }
77    }
78
79    /// Get the reading order of this element, if assigned.
80    pub fn reading_order(&self) -> Option<usize> {
81        match self {
82            ContentElement::Text(t) => t.reading_order,
83            ContentElement::Image(i) => i.reading_order,
84            ContentElement::Path(p) => p.reading_order,
85            ContentElement::Structure(s) => s.reading_order,
86            ContentElement::Table(t) => t.reading_order,
87        }
88    }
89
90    /// Check if this is a text element.
91    pub fn is_text(&self) -> bool {
92        matches!(self, ContentElement::Text(_))
93    }
94
95    /// Check if this is an image element.
96    pub fn is_image(&self) -> bool {
97        matches!(self, ContentElement::Image(_))
98    }
99
100    /// Check if this is a path element.
101    pub fn is_path(&self) -> bool {
102        matches!(self, ContentElement::Path(_))
103    }
104
105    /// Check if this is a table element.
106    pub fn is_table(&self) -> bool {
107        matches!(self, ContentElement::Table(_))
108    }
109
110    /// Get as text content if this is a text element.
111    pub fn as_text(&self) -> Option<&TextContent> {
112        match self {
113            ContentElement::Text(t) => Some(t),
114            _ => None,
115        }
116    }
117
118    /// Get as image content if this is an image element.
119    pub fn as_image(&self) -> Option<&ImageContent> {
120        match self {
121            ContentElement::Image(i) => Some(i),
122            _ => None,
123        }
124    }
125
126    /// Get as path content if this is a path element.
127    pub fn as_path(&self) -> Option<&PathContent> {
128        match self {
129            ContentElement::Path(p) => Some(p),
130            _ => None,
131        }
132    }
133
134    /// Get as table content if this is a table element.
135    pub fn as_table(&self) -> Option<&TableContent> {
136        match self {
137            ContentElement::Table(t) => Some(t),
138            _ => None,
139        }
140    }
141}
142
143/// A structural element for Tagged PDF support.
144///
145/// Represents a node in the PDF structure tree that groups
146/// other content elements semantically.
147#[derive(Debug, Clone)]
148pub struct StructureElement {
149    /// Structure type (e.g., "P", "H1", "Table", "Figure")
150    pub structure_type: String,
151    /// Bounding box encompassing all child elements
152    pub bbox: Rect,
153    /// Children content elements
154    pub children: Vec<ContentElement>,
155    /// Reading order index
156    pub reading_order: Option<usize>,
157    /// Alternate text (for accessibility)
158    pub alt_text: Option<String>,
159    /// Language tag (e.g., "en-US")
160    pub language: Option<String>,
161}
162
163impl Default for StructureElement {
164    fn default() -> Self {
165        Self {
166            structure_type: String::new(),
167            bbox: Rect::new(0.0, 0.0, 0.0, 0.0),
168            children: Vec::new(),
169            reading_order: None,
170            alt_text: None,
171            language: None,
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_content_element_bbox() {
182        let text = TextContent {
183            artifact_type: None,
184            text: "Test".to_string(),
185            bbox: Rect::new(10.0, 20.0, 50.0, 12.0),
186            font: FontSpec::default(),
187            style: TextStyle::default(),
188            reading_order: Some(0),
189            origin: None,
190            rotation_degrees: None,
191            matrix: None,
192        };
193
194        let element = ContentElement::Text(text);
195        let bbox = element.bbox();
196
197        assert_eq!(bbox.x, 10.0);
198        assert_eq!(bbox.y, 20.0);
199        assert_eq!(bbox.width, 50.0);
200        assert_eq!(bbox.height, 12.0);
201    }
202
203    #[test]
204    fn test_content_element_type_checks() {
205        let text = ContentElement::Text(TextContent {
206            artifact_type: None,
207            text: "Test".to_string(),
208            bbox: Rect::new(0.0, 0.0, 10.0, 10.0),
209            font: FontSpec::default(),
210            style: TextStyle::default(),
211            reading_order: None,
212            origin: None,
213            rotation_degrees: None,
214            matrix: None,
215        });
216
217        assert!(text.is_text());
218        assert!(!text.is_image());
219        assert!(!text.is_path());
220        assert!(text.as_text().is_some());
221        assert!(text.as_image().is_none());
222    }
223
224    #[test]
225    fn test_reading_order() {
226        let text = ContentElement::Text(TextContent {
227            artifact_type: None,
228            text: "First".to_string(),
229            bbox: Rect::new(0.0, 0.0, 10.0, 10.0),
230            font: FontSpec::default(),
231            style: TextStyle::default(),
232            reading_order: Some(5),
233            origin: None,
234            rotation_degrees: None,
235            matrix: None,
236        });
237
238        assert_eq!(text.reading_order(), Some(5));
239    }
240}