Skip to main content

pdf_vdiff/
model.rs

1//! Core data models and geometric types for `pdf-vdiff`.
2
3/// 2D Bounding box in PDF user-space points with bottom-left origin.
4/// Invariant: x0 <= x1 and y0 <= y1 (enforced by construction in constructor).
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub struct Rect {
7    pub x0: f32, // Left
8    pub y0: f32, // Bottom
9    pub x1: f32, // Right
10    pub y1: f32, // Top
11}
12
13impl Rect {
14    /// Creates a new bounding box, ensuring `x0 <= x1` and `y0 <= y1`.
15    pub fn new(x0: f32, y0: f32, x1: f32, y1: f32) -> Self {
16        Self {
17            x0: x0.min(x1),
18            y0: y0.min(y1),
19            x1: x0.max(x1),
20            y1: y0.max(y1),
21        }
22    }
23
24    /// Returns the width of the bounding box.
25    pub fn width(&self) -> f32 {
26        (self.x1 - self.x0).max(0.0)
27    }
28
29    /// Returns the height of the bounding box.
30    pub fn height(&self) -> f32 {
31        (self.y1 - self.y0).max(0.0)
32    }
33
34    /// Computes the minimal bounding box containing both `self` and `other`.
35    pub fn union(&self, other: &Rect) -> Rect {
36        Rect {
37            x0: self.x0.min(other.x0),
38            y0: self.y0.min(other.y0),
39            x1: self.x1.max(other.x1),
40            y1: self.y1.max(other.y1),
41        }
42    }
43
44    /// Expands the bounding box by `pad_x` horizontally and `pad_y` vertically.
45    pub fn expand(&self, pad_x: f32, pad_y: f32) -> Rect {
46        Rect {
47            x0: self.x0 - pad_x,
48            y0: self.y0 - pad_y,
49            x1: self.x1 + pad_x,
50            y1: self.y1 + pad_y,
51        }
52    }
53
54    /// Checks if a point is within this bounding box.
55    pub fn contains_point(&self, x: f32, y: f32) -> bool {
56        x >= self.x0 && x <= self.x1 && y >= self.y0 && y <= self.y1
57    }
58
59    /// Checks if this rectangle overlaps with another rectangle.
60    pub fn overlaps(&self, other: &Rect) -> bool {
61        self.x0 < other.x1 && self.x1 > other.x0 && self.y0 < other.y1 && self.y1 > other.y0
62    }
63}
64
65/// Extracted text token with geometric and structural metadata.
66#[derive(Debug, Clone, PartialEq)]
67pub struct TextToken {
68    /// Raw extracted text of the token.
69    pub text: String,
70    /// NFKD normalized text used for sequence matching and diffing.
71    pub normalized_text: String,
72    /// CropBox-normalized bounding box in PDF user points.
73    pub bounds: Rect,
74    /// 0-indexed source page index.
75    pub page_index: usize,
76    /// Spatial column partition index.
77    pub column_index: usize,
78    /// Visual baseline line index within the column.
79    pub line_index: usize,
80    /// True if whitespace originally followed this token in the document.
81    pub trailing_space: bool,
82}
83
84impl TextToken {
85    /// Constructs a new `TextToken`.
86    pub fn new(
87        text: String,
88        normalized_text: String,
89        bounds: Rect,
90        page_index: usize,
91        column_index: usize,
92        line_index: usize,
93        trailing_space: bool,
94    ) -> Self {
95        Self {
96            text,
97            normalized_text,
98            bounds,
99            page_index,
100            column_index,
101            line_index,
102            trailing_space,
103        }
104    }
105}
106
107/// Category of a diff operation on text or lines.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum DiffOpKind {
110    /// Content is identical across both documents.
111    Equal,
112    /// Content is deleted from the Base document.
113    Delete,
114    /// Content is inserted into the Tailored document.
115    Insert,
116    /// Content is modified/replaced between documents.
117    Replace,
118}
119
120/// Highlight rectangle segment rendered on the composite canvas.
121#[derive(Debug, Clone, PartialEq)]
122pub struct HighlightSpan {
123    /// Canvas-projected bounding box.
124    pub bounds: Rect,
125    /// Kind of diff operation.
126    pub op: DiffOpKind,
127    /// True if this highlight marks a specific altered word in a modified line.
128    pub is_modified_token: bool,
129}
130
131impl HighlightSpan {
132    /// Constructs a new `HighlightSpan`.
133    pub fn new(bounds: Rect, op: DiffOpKind, is_modified_token: bool) -> Self {
134        Self {
135            bounds,
136            op,
137            is_modified_token,
138        }
139    }
140}
141
142/// Structured representation of all text extracted from a single PDF page.
143#[derive(Debug, Clone, PartialEq)]
144pub struct PageText {
145    /// Extracted tokens sorted in natural visual reading order.
146    pub tokens: Vec<TextToken>,
147    /// Page width in PDF points.
148    pub width: f32,
149    /// Page height in PDF points.
150    pub height: f32,
151    /// 0-indexed page number.
152    pub page_index: usize,
153}
154
155impl PageText {
156    /// Constructs a new `PageText`.
157    pub fn new(tokens: Vec<TextToken>, width: f32, height: f32, page_index: usize) -> Self {
158        Self {
159            tokens,
160            width,
161            height,
162            page_index,
163        }
164    }
165
166    /// Returns true if the page contains zero text tokens.
167    pub fn is_empty(&self) -> bool {
168        self.tokens.is_empty()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_rect_math() {
178        let r1 = Rect::new(10.0, 20.0, 50.0, 60.0);
179        assert_eq!(r1.width(), 40.0);
180        assert_eq!(r1.height(), 40.0);
181        assert!(r1.contains_point(30.0, 40.0));
182        assert!(!r1.contains_point(5.0, 40.0));
183
184        let r2 = Rect::new(40.0, 50.0, 80.0, 90.0);
185        assert!(r1.overlaps(&r2));
186
187        let u = r1.union(&r2);
188        assert_eq!(u, Rect::new(10.0, 20.0, 80.0, 90.0));
189
190        let exp = r1.expand(0.5, 1.0);
191        assert_eq!(exp, Rect::new(9.5, 19.0, 50.5, 61.0));
192    }
193
194    #[test]
195    fn test_rect_new_inverted_coordinates() {
196        let r = Rect::new(50.0, 60.0, 10.0, 20.0);
197        assert_eq!(
198            r,
199            Rect {
200                x0: 10.0,
201                y0: 20.0,
202                x1: 50.0,
203                y1: 60.0,
204            }
205        );
206        assert_eq!(r.width(), 40.0);
207        assert_eq!(r.height(), 40.0);
208    }
209
210    #[test]
211    fn test_token_and_page_text() {
212        let rect = Rect::new(0.0, 0.0, 100.0, 20.0);
213        let token = TextToken::new(
214            "Hello".to_string(),
215            "Hello".to_string(),
216            rect,
217            0,
218            0,
219            0,
220            true,
221        );
222        assert_eq!(token.text, "Hello");
223        assert!(token.trailing_space);
224
225        let page = PageText::new(vec![token], 612.0, 792.0, 0);
226        assert!(!page.is_empty());
227        assert_eq!(page.tokens.len(), 1);
228    }
229}