1use pdfplumber_core::geometry::BBox;
8
9pub struct PageGeometry {
41 rotation: i32,
42 media_x0: f64,
43 media_y0: f64,
44 native_width: f64,
45 native_height: f64,
46 crop_rx0: f64,
47 crop_ry0: f64,
48 display_width: f64,
49 display_height: f64,
50}
51
52impl PageGeometry {
53 pub fn new(media_box: BBox, crop_box: Option<BBox>, rotation: i32) -> Self {
64 let rotation = rotation.rem_euclid(360);
65
66 let media_x0 = media_box.x0;
67 let media_y0 = media_box.top;
68 let native_width = media_box.width();
69 let native_height = media_box.height();
70
71 let crop = crop_box.unwrap_or(media_box);
72
73 let cx0 = crop.x0 - media_x0;
75 let cy0 = crop.top - media_y0;
76 let cx1 = crop.x1 - media_x0;
77 let cy1 = crop.bottom - media_y0;
78
79 let (crop_rx0, crop_ry0, crop_rx1, crop_ry1) = match rotation {
81 90 => (cy0, native_width - cx1, cy1, native_width - cx0),
82 180 => (
83 native_width - cx1,
84 native_height - cy1,
85 native_width - cx0,
86 native_height - cy0,
87 ),
88 270 => (native_height - cy1, cx0, native_height - cy0, cx1),
89 _ => (cx0, cy0, cx1, cy1), };
91
92 Self {
93 rotation,
94 media_x0,
95 media_y0,
96 native_width,
97 native_height,
98 crop_rx0,
99 crop_ry0,
100 display_width: crop_rx1 - crop_rx0,
101 display_height: crop_ry1 - crop_ry0,
102 }
103 }
104
105 pub fn width(&self) -> f64 {
107 self.display_width
108 }
109
110 pub fn height(&self) -> f64 {
112 self.display_height
113 }
114
115 pub fn rotation(&self) -> i32 {
117 self.rotation
118 }
119
120 pub fn normalize_point(&self, x: f64, y: f64) -> (f64, f64) {
124 let px = x - self.media_x0;
126 let py = y - self.media_y0;
127
128 let (rx, ry) = match self.rotation {
130 90 => (py, self.native_width - px),
131 180 => (self.native_width - px, self.native_height - py),
132 270 => (self.native_height - py, px),
133 _ => (px, py), };
135
136 let cx = rx - self.crop_rx0;
138 let cy = ry - self.crop_ry0;
139
140 (cx, self.display_height - cy)
142 }
143
144 pub fn normalize_bbox(&self, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> BBox {
150 let (x0, y0) = self.normalize_point(min_x, min_y);
151 let (x1, y1) = self.normalize_point(max_x, max_y);
152 BBox::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1))
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 const LETTER_W: f64 = 612.0;
162 const LETTER_H: f64 = 792.0;
163
164 fn letter_media_box() -> BBox {
165 BBox::new(0.0, 0.0, LETTER_W, LETTER_H)
166 }
167
168 fn letter_crop_box() -> BBox {
169 BBox::new(36.0, 36.0, 576.0, 756.0)
171 }
172
173 fn assert_approx(actual: f64, expected: f64, msg: &str) {
174 assert!(
175 (actual - expected).abs() < 0.01,
176 "{msg}: expected {expected}, got {actual}"
177 );
178 }
179
180 fn assert_point_approx(actual: (f64, f64), expected: (f64, f64), msg: &str) {
181 assert_approx(actual.0, expected.0, &format!("{msg} x"));
182 assert_approx(actual.1, expected.1, &format!("{msg} y"));
183 }
184
185 #[test]
188 fn rotate_0_dimensions() {
189 let geo = PageGeometry::new(letter_media_box(), None, 0);
190 assert_approx(geo.width(), 612.0, "width");
191 assert_approx(geo.height(), 792.0, "height");
192 }
193
194 #[test]
195 fn rotate_0_point_near_top() {
196 let geo = PageGeometry::new(letter_media_box(), None, 0);
197 let p = geo.normalize_point(72.0, 720.0);
199 assert_point_approx(p, (72.0, 72.0), "near top");
201 }
202
203 #[test]
204 fn rotate_0_point_near_bottom() {
205 let geo = PageGeometry::new(letter_media_box(), None, 0);
206 let p = geo.normalize_point(72.0, 72.0);
208 assert_point_approx(p, (72.0, 720.0), "near bottom");
210 }
211
212 #[test]
213 fn rotate_0_bbox() {
214 let geo = PageGeometry::new(letter_media_box(), None, 0);
215 let bbox = geo.normalize_bbox(72.0, 717.0, 80.0, 729.0);
216 assert_approx(bbox.x0, 72.0, "x0");
217 assert_approx(bbox.top, 63.0, "top"); assert_approx(bbox.x1, 80.0, "x1");
219 assert_approx(bbox.bottom, 75.0, "bottom"); }
221
222 #[test]
223 fn rotate_0_equivalent_to_simple_y_flip() {
224 let geo = PageGeometry::new(letter_media_box(), None, 0);
225 let (x, y) = geo.normalize_point(72.0, 720.0);
227 assert_approx(x, 72.0, "x unchanged");
228 assert_approx(y, LETTER_H - 720.0, "y matches simple y-flip");
229 }
230
231 #[test]
234 fn rotate_90_dimensions() {
235 let geo = PageGeometry::new(letter_media_box(), None, 90);
236 assert_approx(geo.width(), 792.0, "width swapped");
238 assert_approx(geo.height(), 612.0, "height swapped");
239 }
240
241 #[test]
242 fn rotate_90_point() {
243 let geo = PageGeometry::new(letter_media_box(), None, 90);
244 let p = geo.normalize_point(72.0, 720.0);
245 assert_point_approx(p, (720.0, 72.0), "90° point");
247 }
248
249 #[test]
250 fn rotate_90_bbox() {
251 let geo = PageGeometry::new(letter_media_box(), None, 90);
252 let bbox = geo.normalize_bbox(72.0, 717.0, 80.0, 729.0);
253 assert_approx(bbox.x0, 717.0, "x0");
256 assert_approx(bbox.top, 72.0, "top");
257 assert_approx(bbox.x1, 729.0, "x1");
258 assert_approx(bbox.bottom, 80.0, "bottom");
259 assert_approx(bbox.width(), 12.0, "width");
261 assert_approx(bbox.height(), 8.0, "height");
262 }
263
264 #[test]
267 fn rotate_180_dimensions() {
268 let geo = PageGeometry::new(letter_media_box(), None, 180);
269 assert_approx(geo.width(), 612.0, "width unchanged");
271 assert_approx(geo.height(), 792.0, "height unchanged");
272 }
273
274 #[test]
275 fn rotate_180_point() {
276 let geo = PageGeometry::new(letter_media_box(), None, 180);
277 let p = geo.normalize_point(72.0, 720.0);
278 assert_point_approx(p, (540.0, 720.0), "180° point");
280 }
281
282 #[test]
283 fn rotate_180_bbox() {
284 let geo = PageGeometry::new(letter_media_box(), None, 180);
285 let bbox = geo.normalize_bbox(72.0, 717.0, 80.0, 729.0);
286 assert_approx(bbox.x0, 532.0, "x0");
289 assert_approx(bbox.top, 717.0, "top");
290 assert_approx(bbox.x1, 540.0, "x1");
291 assert_approx(bbox.bottom, 729.0, "bottom");
292 assert_approx(bbox.width(), 8.0, "width");
294 assert_approx(bbox.height(), 12.0, "height");
295 }
296
297 #[test]
300 fn rotate_270_dimensions() {
301 let geo = PageGeometry::new(letter_media_box(), None, 270);
302 assert_approx(geo.width(), 792.0, "width swapped");
304 assert_approx(geo.height(), 612.0, "height swapped");
305 }
306
307 #[test]
308 fn rotate_270_point() {
309 let geo = PageGeometry::new(letter_media_box(), None, 270);
310 let p = geo.normalize_point(72.0, 720.0);
311 assert_point_approx(p, (72.0, 540.0), "270° point");
313 }
314
315 #[test]
316 fn rotate_270_bbox() {
317 let geo = PageGeometry::new(letter_media_box(), None, 270);
318 let bbox = geo.normalize_bbox(72.0, 717.0, 80.0, 729.0);
319 assert_approx(bbox.x0, 63.0, "x0");
322 assert_approx(bbox.top, 532.0, "top");
323 assert_approx(bbox.x1, 75.0, "x1");
324 assert_approx(bbox.bottom, 540.0, "bottom");
325 assert_approx(bbox.width(), 12.0, "width");
327 assert_approx(bbox.height(), 8.0, "height");
328 }
329
330 #[test]
333 fn cropbox_dimensions() {
334 let geo = PageGeometry::new(letter_media_box(), Some(letter_crop_box()), 0);
335 assert_approx(geo.width(), 540.0, "cropped width");
337 assert_approx(geo.height(), 720.0, "cropped height");
338 }
339
340 #[test]
341 fn cropbox_offset_point() {
342 let geo = PageGeometry::new(letter_media_box(), Some(letter_crop_box()), 0);
343 let p = geo.normalize_point(72.0, 720.0);
344 assert_point_approx(p, (36.0, 36.0), "cropped point");
347 }
348
349 #[test]
350 fn cropbox_offset_bbox() {
351 let geo = PageGeometry::new(letter_media_box(), Some(letter_crop_box()), 0);
352 let bbox = geo.normalize_bbox(72.0, 717.0, 80.0, 729.0);
353 assert_approx(bbox.x0, 36.0, "x0");
356 assert_approx(bbox.top, 27.0, "top");
357 assert_approx(bbox.x1, 44.0, "x1");
358 assert_approx(bbox.bottom, 39.0, "bottom");
359 }
360
361 #[test]
364 fn cropbox_with_rotation_90_dimensions() {
365 let geo = PageGeometry::new(letter_media_box(), Some(letter_crop_box()), 90);
366 assert_approx(geo.width(), 720.0, "rotated+cropped width");
370 assert_approx(geo.height(), 540.0, "rotated+cropped height");
371 }
372
373 #[test]
374 fn cropbox_with_rotation_90_point() {
375 let geo = PageGeometry::new(letter_media_box(), Some(letter_crop_box()), 90);
376 let p = geo.normalize_point(72.0, 720.0);
377 assert_point_approx(p, (684.0, 36.0), "90° + crop");
381 }
382
383 #[test]
384 fn cropbox_with_rotation_180_point() {
385 let geo = PageGeometry::new(letter_media_box(), Some(letter_crop_box()), 180);
386 let p = geo.normalize_point(72.0, 720.0);
387 assert_point_approx(p, (504.0, 684.0), "180° + crop");
393 }
394
395 #[test]
396 fn cropbox_with_rotation_270_point() {
397 let geo = PageGeometry::new(letter_media_box(), Some(letter_crop_box()), 270);
398 let p = geo.normalize_point(72.0, 720.0);
399 assert_point_approx(p, (36.0, 504.0), "270° + crop");
405 }
406
407 #[test]
410 fn non_zero_mediabox_origin() {
411 let media_box = BBox::new(100.0, 100.0, 712.0, 892.0);
412 let geo = PageGeometry::new(media_box, None, 0);
413 assert_approx(geo.width(), 612.0, "width");
414 assert_approx(geo.height(), 792.0, "height");
415
416 let p = geo.normalize_point(172.0, 820.0);
417 assert_point_approx(p, (72.0, 72.0), "shifted origin");
420 }
421
422 #[test]
423 fn non_zero_mediabox_with_rotation_90() {
424 let media_box = BBox::new(50.0, 50.0, 662.0, 842.0);
425 let geo = PageGeometry::new(media_box, None, 90);
426 assert_approx(geo.width(), 792.0, "width swapped");
427 assert_approx(geo.height(), 612.0, "height swapped");
428
429 let p = geo.normalize_point(122.0, 770.0);
430 assert_point_approx(p, (720.0, 72.0), "shifted + 90°");
434 }
435
436 #[test]
439 fn negative_rotation_normalized() {
440 let geo = PageGeometry::new(letter_media_box(), None, -90);
441 assert_eq!(geo.rotation(), 270);
442 assert_approx(geo.width(), 792.0, "width for -90°");
443 assert_approx(geo.height(), 612.0, "height for -90°");
444 }
445
446 #[test]
447 fn rotation_360_normalized_to_0() {
448 let geo = PageGeometry::new(letter_media_box(), None, 360);
449 assert_eq!(geo.rotation(), 0);
450 assert_approx(geo.width(), 612.0, "width for 360°");
451 assert_approx(geo.height(), 792.0, "height for 360°");
452 }
453
454 #[test]
455 fn rotation_450_normalized_to_90() {
456 let geo = PageGeometry::new(letter_media_box(), None, 450);
457 assert_eq!(geo.rotation(), 90);
458 }
459
460 #[test]
463 fn origin_point_transforms_correctly() {
464 let geo = PageGeometry::new(letter_media_box(), None, 0);
465 let p = geo.normalize_point(0.0, 0.0);
466 assert_point_approx(p, (0.0, 792.0), "origin");
468 }
469
470 #[test]
471 fn top_right_corner_transforms_correctly() {
472 let geo = PageGeometry::new(letter_media_box(), None, 0);
473 let p = geo.normalize_point(612.0, 792.0);
474 assert_point_approx(p, (612.0, 0.0), "top-right corner");
476 }
477
478 #[test]
481 fn rotation_accessor() {
482 assert_eq!(PageGeometry::new(letter_media_box(), None, 0).rotation(), 0);
483 assert_eq!(
484 PageGeometry::new(letter_media_box(), None, 90).rotation(),
485 90
486 );
487 assert_eq!(
488 PageGeometry::new(letter_media_box(), None, 180).rotation(),
489 180
490 );
491 assert_eq!(
492 PageGeometry::new(letter_media_box(), None, 270).rotation(),
493 270
494 );
495 }
496
497 #[test]
500 fn small_cropbox_clips_dimensions() {
501 let crop = BBox::new(100.0, 200.0, 300.0, 500.0);
503 let geo = PageGeometry::new(letter_media_box(), Some(crop), 0);
504 assert_approx(geo.width(), 200.0, "small crop width");
505 assert_approx(geo.height(), 300.0, "small crop height");
506 }
507
508 #[test]
509 fn small_cropbox_offsets_coordinates() {
510 let crop = BBox::new(100.0, 200.0, 300.0, 500.0);
511 let geo = PageGeometry::new(letter_media_box(), Some(crop), 0);
512 let p = geo.normalize_point(100.0, 200.0);
514 assert_point_approx(p, (0.0, 300.0), "crop origin");
515
516 let p2 = geo.normalize_point(300.0, 500.0);
518 assert_point_approx(p2, (200.0, 0.0), "crop top-right");
519 }
520
521 #[test]
524 fn square_page_rotate_90() {
525 let media = BBox::new(0.0, 0.0, 500.0, 500.0);
526 let geo = PageGeometry::new(media, None, 90);
527 assert_approx(geo.width(), 500.0, "width");
529 assert_approx(geo.height(), 500.0, "height");
530 }
531}