Skip to main content

justpdf_render/
bbox_device.rs

1//! BBox Device: a lightweight device that tracks the bounding box of all drawing operations
2//! without actually rendering pixels.
3
4use justpdf_core::page::{Rect, collect_pages};
5use justpdf_core::PdfDocument;
6use justpdf_core::content::{ContentOp, Operand, parse_content_stream};
7use justpdf_core::object::PdfObject;
8use justpdf_core::page::PageInfo;
9
10use crate::error::{RenderError, Result};
11use crate::graphics_state::Matrix;
12
13/// A bounding box tracker that records the extent of all drawing operations.
14pub struct BBoxDevice {
15    min_x: f64,
16    min_y: f64,
17    max_x: f64,
18    max_y: f64,
19    has_content: bool,
20    /// Transform from PDF user space to page space.
21    #[allow(dead_code)]
22    page_transform: Matrix,
23    /// Current transformation matrix.
24    ctm: Matrix,
25    ctm_stack: Vec<Matrix>,
26}
27
28impl BBoxDevice {
29    pub fn new(page_transform: Matrix) -> Self {
30        Self {
31            min_x: f64::MAX,
32            min_y: f64::MAX,
33            max_x: f64::MIN,
34            max_y: f64::MIN,
35            has_content: false,
36            page_transform,
37            ctm: Matrix::identity(),
38            ctm_stack: Vec::new(),
39        }
40    }
41
42    /// Get the computed bounding box, or None if no content was drawn.
43    pub fn bbox(&self) -> Option<Rect> {
44        if !self.has_content {
45            return None;
46        }
47        Some(Rect {
48            llx: self.min_x,
49            lly: self.min_y,
50            urx: self.max_x,
51            ury: self.max_y,
52        })
53    }
54
55    /// Extend the bounding box with a point in PDF user space.
56    fn extend_point(&mut self, x: f64, y: f64) {
57        // Transform point through CTM (but NOT page_transform — we want PDF coordinates)
58        let (tx, ty) = self.ctm.transform_point(x, y);
59        self.min_x = self.min_x.min(tx);
60        self.min_y = self.min_y.min(ty);
61        self.max_x = self.max_x.max(tx);
62        self.max_y = self.max_y.max(ty);
63        self.has_content = true;
64    }
65
66    /// Extend the bounding box with a rectangle in PDF user space.
67    pub fn extend_rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
68        self.extend_point(x, y);
69        self.extend_point(x + w, y);
70        self.extend_point(x + w, y + h);
71        self.extend_point(x, y + h);
72    }
73
74    /// Process content stream operations to compute bounding box.
75    pub fn process_ops(&mut self, ops: &[ContentOp]) {
76        let mut path_points: Vec<(f64, f64)> = Vec::new();
77        let mut text_matrix = Matrix::identity();
78        let mut text_line_matrix = Matrix::identity();
79        let mut font_size = 12.0_f64;
80        let mut text_rise = 0.0_f64;
81
82        for op in ops {
83            let operator = op.operator_str();
84            let operands = &op.operands;
85
86            match operator {
87                "q" => self.ctm_stack.push(self.ctm),
88                "Q" => {
89                    if let Some(m) = self.ctm_stack.pop() {
90                        self.ctm = m;
91                    }
92                }
93                "cm" => {
94                    if operands.len() >= 6 {
95                        let m = Matrix {
96                            a: f(operands, 0),
97                            b: f(operands, 1),
98                            c: f(operands, 2),
99                            d: f(operands, 3),
100                            e: f(operands, 4),
101                            f: f(operands, 5),
102                        };
103                        self.ctm = m.concat(&self.ctm);
104                    }
105                }
106
107                // Path construction
108                "m" | "l" => {
109                    if operands.len() >= 2 {
110                        path_points.push((f(operands, 0), f(operands, 1)));
111                    }
112                }
113                "c" => {
114                    if operands.len() >= 6 {
115                        path_points.push((f(operands, 0), f(operands, 1)));
116                        path_points.push((f(operands, 2), f(operands, 3)));
117                        path_points.push((f(operands, 4), f(operands, 5)));
118                    }
119                }
120                "v" => {
121                    if operands.len() >= 4 {
122                        path_points.push((f(operands, 0), f(operands, 1)));
123                        path_points.push((f(operands, 2), f(operands, 3)));
124                    }
125                }
126                "y" => {
127                    if operands.len() >= 4 {
128                        path_points.push((f(operands, 0), f(operands, 1)));
129                        path_points.push((f(operands, 2), f(operands, 3)));
130                    }
131                }
132                "re" => {
133                    if operands.len() >= 4 {
134                        let x = f(operands, 0);
135                        let y = f(operands, 1);
136                        let w = f(operands, 2);
137                        let h = f(operands, 3);
138                        path_points.push((x, y));
139                        path_points.push((x + w, y));
140                        path_points.push((x + w, y + h));
141                        path_points.push((x, y + h));
142                    }
143                }
144
145                // Path painting — flush points to bbox
146                "S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => {
147                    for &(x, y) in &path_points {
148                        self.extend_point(x, y);
149                    }
150                    path_points.clear();
151                }
152                "n" => {
153                    path_points.clear();
154                }
155
156                // Text
157                "BT" => {
158                    text_matrix = Matrix::identity();
159                    text_line_matrix = Matrix::identity();
160                }
161                "Tf" => {
162                    if operands.len() > 1 {
163                        font_size = f(operands, 1);
164                    }
165                }
166                "Ts" => {
167                    text_rise = f(operands, 0);
168                }
169                "Td" | "TD" => {
170                    let tx = f(operands, 0);
171                    let ty = f(operands, 1);
172                    let t = Matrix::translate(tx, ty);
173                    text_line_matrix = t.concat(&text_line_matrix);
174                    text_matrix = text_line_matrix;
175                }
176                "Tm" => {
177                    if operands.len() >= 6 {
178                        let m = Matrix {
179                            a: f(operands, 0),
180                            b: f(operands, 1),
181                            c: f(operands, 2),
182                            d: f(operands, 3),
183                            e: f(operands, 4),
184                            f: f(operands, 5),
185                        };
186                        text_matrix = m;
187                        text_line_matrix = m;
188                    }
189                }
190                "Tj" | "'" => {
191                    // Approximate text bbox: a rectangle at text position
192                    let trm = text_matrix.concat(&self.ctm);
193                    let (tx, ty) = (trm.e, trm.f + text_rise);
194                    self.extend_point_raw(tx, ty);
195                    self.extend_point_raw(tx, ty + font_size);
196                }
197                "TJ" => {
198                    let trm = text_matrix.concat(&self.ctm);
199                    let (tx, ty) = (trm.e, trm.f + text_rise);
200                    self.extend_point_raw(tx, ty);
201                    self.extend_point_raw(tx, ty + font_size);
202                }
203
204                // Image XObject
205                "Do" => {
206                    // Image/Form XObject: bbox is the CTM-transformed unit square
207                    self.extend_point(0.0, 0.0);
208                    self.extend_point(1.0, 0.0);
209                    self.extend_point(1.0, 1.0);
210                    self.extend_point(0.0, 1.0);
211                }
212
213                _ => {}
214            }
215        }
216    }
217
218    /// Extend bbox with a point already in page coordinates (not through CTM).
219    fn extend_point_raw(&mut self, x: f64, y: f64) {
220        self.min_x = self.min_x.min(x);
221        self.min_y = self.min_y.min(y);
222        self.max_x = self.max_x.max(x);
223        self.max_y = self.max_y.max(y);
224        self.has_content = true;
225    }
226}
227
228/// Compute the content bounding box for a page (in PDF user space coordinates).
229pub fn compute_page_bbox(doc: &PdfDocument, page_index: usize) -> Result<Option<Rect>> {
230    let pages = collect_pages(doc)?;
231    let page = pages
232        .get(page_index)
233        .ok_or_else(|| RenderError::InvalidDimensions {
234            detail: format!("page index {page_index} out of range"),
235        })?
236        .clone();
237
238    let media_box = page.crop_box.unwrap_or(page.media_box);
239    let page_transform = crate::render::compute_page_transform(&media_box, 1.0, page.rotate);
240
241    let mut bbox = BBoxDevice::new(page_transform);
242
243    // Get content stream
244    let content_data = get_page_content(doc, &page)?;
245    if content_data.is_empty() {
246        return Ok(None);
247    }
248
249    let ops = parse_content_stream(&content_data).map_err(RenderError::Core)?;
250    bbox.process_ops(&ops);
251
252    Ok(bbox.bbox())
253}
254
255/// Helper to get page content data (simplified version).
256fn get_page_content(doc: &PdfDocument, page: &PageInfo) -> Result<Vec<u8>> {
257    let contents = match &page.contents_ref {
258        Some(c) => c.clone(),
259        None => return Ok(Vec::new()),
260    };
261
262    match &contents {
263        PdfObject::Reference(r) => {
264            let r = r.clone();
265            let obj = doc.resolve(&r)?;
266            match obj {
267                PdfObject::Stream { dict, data } => {
268                    Ok(doc.decode_stream(&dict, &data).unwrap_or_default())
269                }
270                PdfObject::Array(arr) => concat_streams(doc, &arr),
271                _ => Ok(Vec::new()),
272            }
273        }
274        PdfObject::Stream { dict, data } => {
275            Ok(doc.decode_stream(dict, data).unwrap_or_default())
276        }
277        PdfObject::Array(arr) => {
278            let arr = arr.clone();
279            concat_streams(doc, &arr)
280        }
281        _ => Ok(Vec::new()),
282    }
283}
284
285fn concat_streams(doc: &PdfDocument, arr: &[PdfObject]) -> Result<Vec<u8>> {
286    let mut combined = Vec::new();
287    for item in arr {
288        let obj = match item {
289            PdfObject::Reference(r) => {
290                let r = r.clone();
291                doc.resolve(&r)?
292            }
293            other => other.clone(),
294        };
295        if let PdfObject::Stream { dict, data } = obj
296            && let Ok(decoded) = doc.decode_stream(&dict, &data)
297        {
298            combined.extend_from_slice(&decoded);
299            combined.push(b' ');
300        }
301    }
302    Ok(combined)
303}
304
305fn f(operands: &[Operand], idx: usize) -> f64 {
306    operands.get(idx).and_then(|o| o.as_f64()).unwrap_or(0.0)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_bbox_no_content() {
315        let bbox = BBoxDevice::new(Matrix::identity());
316        assert!(bbox.bbox().is_none());
317    }
318
319    #[test]
320    fn test_bbox_single_point() {
321        let mut bbox = BBoxDevice::new(Matrix::identity());
322        bbox.extend_point(10.0, 20.0);
323        let r = bbox.bbox().unwrap();
324        assert!((r.llx - 10.0).abs() < 0.001);
325        assert!((r.lly - 20.0).abs() < 0.001);
326    }
327
328    #[test]
329    fn test_bbox_rectangle() {
330        let mut bbox = BBoxDevice::new(Matrix::identity());
331        bbox.extend_rect(10.0, 20.0, 100.0, 50.0);
332        let r = bbox.bbox().unwrap();
333        assert!((r.llx - 10.0).abs() < 0.001);
334        assert!((r.lly - 20.0).abs() < 0.001);
335        assert!((r.urx - 110.0).abs() < 0.001);
336        assert!((r.ury - 70.0).abs() < 0.001);
337    }
338}