Skip to main content

edgeparse_core/models/
bbox.rs

1//! BoundingBox — Core geometry type for all positioned PDF elements.
2
3use serde::{Deserialize, Serialize};
4
5/// Epsilon for floating-point comparisons in bounding box operations.
6const BBOX_EPSILON: f64 = 0.0001;
7
8/// A rectangular bounding box in PDF coordinate space.
9///
10/// PDF coordinates: origin at bottom-left, Y increases upward.
11/// 72 points = 1 inch.
12///
13/// ```text
14/// (leftX, topY) ──────── (rightX, topY)
15///       │                      │
16///       │      Element         │
17///       │                      │
18/// (leftX, bottomY) ──── (rightX, bottomY)
19/// ```
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BoundingBox {
22    /// Page number (1-based)
23    pub page_number: Option<u32>,
24    /// Last page number (for cross-page elements)
25    pub last_page_number: Option<u32>,
26    /// Left X coordinate
27    pub left_x: f64,
28    /// Bottom Y coordinate
29    pub bottom_y: f64,
30    /// Right X coordinate
31    pub right_x: f64,
32    /// Top Y coordinate
33    pub top_y: f64,
34}
35
36impl PartialEq for BoundingBox {
37    fn eq(&self, other: &Self) -> bool {
38        self.page_number == other.page_number
39            && self.last_page_number == other.last_page_number
40            && (self.left_x - other.left_x).abs() < BBOX_EPSILON
41            && (self.bottom_y - other.bottom_y).abs() < BBOX_EPSILON
42            && (self.right_x - other.right_x).abs() < BBOX_EPSILON
43            && (self.top_y - other.top_y).abs() < BBOX_EPSILON
44    }
45}
46
47impl BoundingBox {
48    /// Create a new BoundingBox.
49    pub fn new(page: Option<u32>, left_x: f64, bottom_y: f64, right_x: f64, top_y: f64) -> Self {
50        Self {
51            page_number: page,
52            last_page_number: page,
53            left_x,
54            bottom_y,
55            right_x,
56            top_y,
57        }
58    }
59
60    /// Create an empty bounding box (zero area at origin).
61    pub fn empty() -> Self {
62        Self {
63            page_number: None,
64            last_page_number: None,
65            left_x: 0.0,
66            bottom_y: 0.0,
67            right_x: 0.0,
68            top_y: 0.0,
69        }
70    }
71
72    /// Width of the bounding box.
73    pub fn width(&self) -> f64 {
74        self.right_x - self.left_x
75    }
76
77    /// Height of the bounding box.
78    pub fn height(&self) -> f64 {
79        self.top_y - self.bottom_y
80    }
81
82    /// Area of the bounding box.
83    pub fn area(&self) -> f64 {
84        let w = self.width();
85        let h = self.height();
86        if w < 0.0 || h < 0.0 {
87            0.0
88        } else {
89            w * h
90        }
91    }
92
93    /// Center X coordinate.
94    pub fn center_x(&self) -> f64 {
95        (self.left_x + self.right_x) / 2.0
96    }
97
98    /// Center Y coordinate.
99    pub fn center_y(&self) -> f64 {
100        (self.bottom_y + self.top_y) / 2.0
101    }
102
103    /// Whether this bounding box has zero or negative area.
104    pub fn is_empty(&self) -> bool {
105        self.width() <= BBOX_EPSILON || self.height() <= BBOX_EPSILON
106    }
107
108    /// Whether this element is on a single page.
109    pub fn is_one_page(&self) -> bool {
110        match (self.page_number, self.last_page_number) {
111            (Some(p), Some(lp)) => p == lp,
112            (Some(_), None) | (None, None) => true,
113            _ => false,
114        }
115    }
116
117    /// Whether this element spans multiple pages.
118    pub fn is_multi_page(&self) -> bool {
119        !self.is_one_page()
120    }
121
122    /// Normalize: ensure left < right and bottom < top.
123    pub fn normalize(&mut self) {
124        if self.left_x > self.right_x {
125            std::mem::swap(&mut self.left_x, &mut self.right_x);
126        }
127        if self.bottom_y > self.top_y {
128            std::mem::swap(&mut self.bottom_y, &mut self.top_y);
129        }
130    }
131
132    /// Compute the union of two bounding boxes.
133    pub fn union(&self, other: &BoundingBox) -> BoundingBox {
134        BoundingBox {
135            page_number: self.page_number.or(other.page_number),
136            last_page_number: match (self.last_page_number, other.last_page_number) {
137                (Some(a), Some(b)) => Some(a.max(b)),
138                (Some(a), None) => Some(a),
139                (None, Some(b)) => Some(b),
140                (None, None) => None,
141            },
142            left_x: self.left_x.min(other.left_x),
143            bottom_y: self.bottom_y.min(other.bottom_y),
144            right_x: self.right_x.max(other.right_x),
145            top_y: self.top_y.max(other.top_y),
146        }
147    }
148
149    /// Whether this bounding box overlaps with another.
150    pub fn overlaps(&self, other: &BoundingBox) -> bool {
151        self.left_x < other.right_x
152            && self.right_x > other.left_x
153            && self.bottom_y < other.top_y
154            && self.top_y > other.bottom_y
155    }
156
157    /// Whether this bounding box fully contains another.
158    pub fn contains(&self, other: &BoundingBox) -> bool {
159        self.left_x <= other.left_x + BBOX_EPSILON
160            && self.right_x >= other.right_x - BBOX_EPSILON
161            && self.bottom_y <= other.bottom_y + BBOX_EPSILON
162            && self.top_y >= other.top_y - BBOX_EPSILON
163    }
164
165    /// Weakly contains: allows small margin of error.
166    pub fn weakly_contains(&self, other: &BoundingBox) -> bool {
167        let margin = 1.0; // 1 PDF point tolerance
168        self.left_x <= other.left_x + margin
169            && self.right_x >= other.right_x - margin
170            && self.bottom_y <= other.bottom_y + margin
171            && self.top_y >= other.top_y - margin
172    }
173
174    /// Compute intersection area percentage relative to `other`.
175    pub fn intersection_percent(&self, other: &BoundingBox) -> f64 {
176        let ix_left = self.left_x.max(other.left_x);
177        let ix_right = self.right_x.min(other.right_x);
178        let iy_bottom = self.bottom_y.max(other.bottom_y);
179        let iy_top = self.top_y.min(other.top_y);
180
181        if ix_left >= ix_right || iy_bottom >= iy_top {
182            return 0.0;
183        }
184
185        let intersection_area = (ix_right - ix_left) * (iy_top - iy_bottom);
186        let other_area = other.area();
187
188        if other_area <= BBOX_EPSILON {
189            return 0.0;
190        }
191
192        intersection_area / other_area
193    }
194
195    /// Vertical intersection percentage.
196    pub fn vertical_intersection_percent(&self, other: &BoundingBox) -> f64 {
197        let iy_bottom = self.bottom_y.max(other.bottom_y);
198        let iy_top = self.top_y.min(other.top_y);
199
200        if iy_bottom >= iy_top {
201            return 0.0;
202        }
203
204        let intersection_height = iy_top - iy_bottom;
205        let other_height = other.height();
206
207        if other_height <= BBOX_EPSILON {
208            return 0.0;
209        }
210
211        intersection_height / other_height
212    }
213
214    /// Vertical gap between two bounding boxes.
215    pub fn vertical_gap(&self, other: &BoundingBox) -> f64 {
216        if self.top_y < other.bottom_y {
217            other.bottom_y - self.top_y
218        } else if other.top_y < self.bottom_y {
219            self.bottom_y - other.top_y
220        } else {
221            0.0 // overlapping
222        }
223    }
224
225    /// Horizontal gap between two bounding boxes.
226    pub fn horizontal_gap(&self, other: &BoundingBox) -> f64 {
227        if self.right_x < other.left_x {
228            other.left_x - self.right_x
229        } else if other.right_x < self.left_x {
230            self.left_x - other.right_x
231        } else {
232            0.0 // overlapping
233        }
234    }
235
236    /// Whether two bounding boxes horizontally overlap.
237    pub fn are_horizontal_overlapping(&self, other: &BoundingBox) -> bool {
238        self.left_x < other.right_x && self.right_x > other.left_x
239    }
240
241    /// Whether two bounding boxes vertically overlap.
242    pub fn are_vertical_overlapping(&self, other: &BoundingBox) -> bool {
243        self.bottom_y < other.top_y && self.top_y > other.bottom_y
244    }
245
246    /// Scale the bounding box by a factor.
247    pub fn scale(&mut self, factor: f64) {
248        let cx = self.center_x();
249        let cy = self.center_y();
250        let half_w = self.width() * factor / 2.0;
251        let half_h = self.height() * factor / 2.0;
252        self.left_x = cx - half_w;
253        self.right_x = cx + half_w;
254        self.bottom_y = cy - half_h;
255        self.top_y = cy + half_h;
256    }
257
258    /// Translate the bounding box by (dx, dy).
259    pub fn translate(&mut self, dx: f64, dy: f64) {
260        self.left_x += dx;
261        self.right_x += dx;
262        self.bottom_y += dy;
263        self.top_y += dy;
264    }
265
266    /// Serialize to JSON array format: [leftX, bottomY, rightX, topY]
267    pub fn to_json_array(&self) -> [f64; 4] {
268        [self.left_x, self.bottom_y, self.right_x, self.top_y]
269    }
270}
271
272/// A multi-bounding-box: outer boundary + inner fragments.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct MultiBoundingBox {
275    /// Outer boundary encompassing all inner boxes
276    pub outer: BoundingBox,
277    /// Inner fragment bounding boxes
278    pub inner: Vec<BoundingBox>,
279}
280
281/// A point with radius (used for line endpoints).
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct Vertex {
284    /// X coordinate
285    pub x: f64,
286    /// Y coordinate
287    pub y: f64,
288    /// Radius (for round line caps)
289    pub radius: f64,
290}
291
292impl Vertex {
293    /// Create a new vertex.
294    pub fn new(x: f64, y: f64, radius: f64) -> Self {
295        Self { x, y, radius }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_bbox_new() {
305        let bbox = BoundingBox::new(Some(1), 10.0, 20.0, 100.0, 80.0);
306        assert_eq!(bbox.page_number, Some(1));
307        assert_eq!(bbox.left_x, 10.0);
308        assert_eq!(bbox.bottom_y, 20.0);
309        assert_eq!(bbox.right_x, 100.0);
310        assert_eq!(bbox.top_y, 80.0);
311    }
312
313    #[test]
314    fn test_bbox_dimensions() {
315        let bbox = BoundingBox::new(Some(1), 10.0, 20.0, 110.0, 80.0);
316        assert!((bbox.width() - 100.0).abs() < BBOX_EPSILON);
317        assert!((bbox.height() - 60.0).abs() < BBOX_EPSILON);
318        assert!((bbox.area() - 6000.0).abs() < BBOX_EPSILON);
319        assert!((bbox.center_x() - 60.0).abs() < BBOX_EPSILON);
320        assert!((bbox.center_y() - 50.0).abs() < BBOX_EPSILON);
321    }
322
323    #[test]
324    fn test_bbox_empty() {
325        let bbox = BoundingBox::empty();
326        assert!(bbox.is_empty());
327        assert_eq!(bbox.area(), 0.0);
328    }
329
330    #[test]
331    fn test_bbox_normalize() {
332        let mut bbox = BoundingBox::new(Some(1), 100.0, 80.0, 10.0, 20.0);
333        bbox.normalize();
334        assert_eq!(bbox.left_x, 10.0);
335        assert_eq!(bbox.bottom_y, 20.0);
336        assert_eq!(bbox.right_x, 100.0);
337        assert_eq!(bbox.top_y, 80.0);
338    }
339
340    #[test]
341    fn test_bbox_union() {
342        let a = BoundingBox::new(Some(1), 10.0, 20.0, 50.0, 60.0);
343        let b = BoundingBox::new(Some(1), 30.0, 10.0, 80.0, 50.0);
344        let u = a.union(&b);
345        assert_eq!(u.left_x, 10.0);
346        assert_eq!(u.bottom_y, 10.0);
347        assert_eq!(u.right_x, 80.0);
348        assert_eq!(u.top_y, 60.0);
349    }
350
351    #[test]
352    fn test_bbox_overlaps() {
353        let a = BoundingBox::new(Some(1), 0.0, 0.0, 50.0, 50.0);
354        let b = BoundingBox::new(Some(1), 25.0, 25.0, 75.0, 75.0);
355        let c = BoundingBox::new(Some(1), 60.0, 60.0, 100.0, 100.0);
356        assert!(a.overlaps(&b));
357        assert!(!a.overlaps(&c));
358    }
359
360    #[test]
361    fn test_bbox_contains() {
362        let outer = BoundingBox::new(Some(1), 0.0, 0.0, 100.0, 100.0);
363        let inner = BoundingBox::new(Some(1), 10.0, 10.0, 90.0, 90.0);
364        let partial = BoundingBox::new(Some(1), 50.0, 50.0, 150.0, 150.0);
365        assert!(outer.contains(&inner));
366        assert!(!outer.contains(&partial));
367    }
368
369    #[test]
370    fn test_bbox_intersection_percent() {
371        let a = BoundingBox::new(Some(1), 0.0, 0.0, 100.0, 100.0);
372        let b = BoundingBox::new(Some(1), 50.0, 50.0, 150.0, 150.0);
373        // Intersection = 50x50 = 2500, b area = 100x100 = 10000
374        let pct = a.intersection_percent(&b);
375        assert!((pct - 0.25).abs() < 0.01);
376    }
377
378    #[test]
379    fn test_bbox_vertical_gap() {
380        let a = BoundingBox::new(Some(1), 0.0, 50.0, 100.0, 100.0);
381        let b = BoundingBox::new(Some(1), 0.0, 0.0, 100.0, 30.0);
382        assert!((a.vertical_gap(&b) - 20.0).abs() < BBOX_EPSILON);
383    }
384
385    #[test]
386    fn test_bbox_horizontal_gap() {
387        let a = BoundingBox::new(Some(1), 0.0, 0.0, 50.0, 100.0);
388        let b = BoundingBox::new(Some(1), 80.0, 0.0, 150.0, 100.0);
389        assert!((a.horizontal_gap(&b) - 30.0).abs() < BBOX_EPSILON);
390    }
391
392    #[test]
393    fn test_bbox_is_one_page() {
394        let mut bbox = BoundingBox::new(Some(1), 0.0, 0.0, 100.0, 100.0);
395        assert!(bbox.is_one_page());
396        bbox.last_page_number = Some(3);
397        assert!(bbox.is_multi_page());
398    }
399
400    #[test]
401    fn test_bbox_translate() {
402        let mut bbox = BoundingBox::new(Some(1), 10.0, 20.0, 50.0, 60.0);
403        bbox.translate(5.0, -10.0);
404        assert!((bbox.left_x - 15.0).abs() < BBOX_EPSILON);
405        assert!((bbox.bottom_y - 10.0).abs() < BBOX_EPSILON);
406        assert!((bbox.right_x - 55.0).abs() < BBOX_EPSILON);
407        assert!((bbox.top_y - 50.0).abs() < BBOX_EPSILON);
408    }
409
410    #[test]
411    fn test_bbox_to_json_array() {
412        let bbox = BoundingBox::new(Some(1), 10.0, 20.0, 100.0, 80.0);
413        let arr = bbox.to_json_array();
414        assert_eq!(arr, [10.0, 20.0, 100.0, 80.0]);
415    }
416
417    #[test]
418    fn test_vertex() {
419        let v = Vertex::new(10.0, 20.0, 1.5);
420        assert_eq!(v.x, 10.0);
421        assert_eq!(v.y, 20.0);
422        assert_eq!(v.radius, 1.5);
423    }
424}