Skip to main content

pdf_inspector/extractor/
display_frame.rs

1//! The rendered frame of a page: the visible page box turned clockwise by
2//! the page's inheritable `/Rotate`, the way renderers draw it.
3//!
4//! Positioned items leave the crate in the *sheet* frame — the visible page
5//! box as laid out in the content stream, `/Rotate` not applied, with a page
6//! whose text is predominantly rotated turned into a synthetic landscape
7//! frame (see [`PageRotation`]) — and the region readers take their rects in
8//! the same frame. A caller pairing the crate with a page renderer sees the
9//! page turned by `/Rotate` instead. [`PositionFrame::Display`] reports items
10//! in, and reads regions from, that rendered frame.
11
12use std::collections::HashMap;
13
14use lopdf::{Document, Object, ObjectId};
15
16use super::geometry::{normalize_degrees, PageRotation};
17use super::page_box::{visible_page_box, PageBox};
18use crate::types::{ItemType, TextItem};
19
20/// Coordinate frame positioned items are reported in and region rects are
21/// read in.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum PositionFrame {
24    /// The visible page box (`CropBox ∩ MediaBox`, else the MediaBox) as laid
25    /// out in the content stream, `/Rotate` not applied. Items use the box's
26    /// lower-left corner as origin with `y` growing upward; regions use its
27    /// top-left corner with `y` growing downward. A page whose text is
28    /// predominantly rotated is turned so that text reads left-to-right (see
29    /// [`PageRotation`]), and its items live in that turned frame.
30    #[default]
31    Sheet,
32    /// The rendered page: the visible page box turned clockwise by the
33    /// page's inheritable `/Rotate`, with the same origin conventions as
34    /// [`PositionFrame::Sheet`]. The turn of a predominantly rotated page is
35    /// undone first, so every item sits where a renderer draws it and a
36    /// region rect taken from a rendered page image selects the text under
37    /// it.
38    Display,
39}
40
41/// A page's `/Rotate`, normalised to a right angle.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub(crate) enum PageRotate {
44    #[default]
45    Rotate0,
46    Rotate90,
47    Rotate180,
48    Rotate270,
49}
50
51impl PageRotate {
52    /// Snap an angle to a right angle in `[0, 360)` the way renderers do:
53    /// negatives fold (`-90` → `270`), oversized values wrap (`450` → `90`),
54    /// and anything else rounds to the nearest right angle. A non-finite
55    /// value reads as `0`.
56    pub(crate) fn from_degrees(degrees: f64) -> PageRotate {
57        if !degrees.is_finite() {
58            return PageRotate::Rotate0;
59        }
60        let folded = degrees.rem_euclid(360.0);
61        match (((folded + 45.0) / 90.0).floor() as i64).rem_euclid(4) {
62            0 => PageRotate::Rotate0,
63            1 => PageRotate::Rotate90,
64            2 => PageRotate::Rotate180,
65            _ => PageRotate::Rotate270,
66        }
67    }
68
69    /// The clockwise turn a renderer applies to the sheet, in degrees.
70    pub(crate) fn degrees(self) -> f32 {
71        match self {
72            PageRotate::Rotate0 => 0.0,
73            PageRotate::Rotate90 => 90.0,
74            PageRotate::Rotate180 => 180.0,
75            PageRotate::Rotate270 => 270.0,
76        }
77    }
78
79    /// Width and height of the rendered page: the visible box's extents,
80    /// swapped by a quarter turn.
81    pub(crate) fn display_size(self, sheet: &PageBox) -> (f32, f32) {
82        let (width, height) = (sheet.width(), sheet.height());
83        match self {
84            PageRotate::Rotate0 | PageRotate::Rotate180 => (width, height),
85            PageRotate::Rotate90 | PageRotate::Rotate270 => (height, width),
86        }
87    }
88
89    /// Turn a sheet-frame box (lower-left origin, `y` up) into the display
90    /// frame. Both edges are normalised first, so the result always has
91    /// non-negative extents.
92    pub(crate) fn sheet_box_to_display(
93        self,
94        sheet: &PageBox,
95        x: f32,
96        y: f32,
97        width: f32,
98        height: f32,
99    ) -> (f32, f32, f32, f32) {
100        let (x0, x1) = (x.min(x + width), x.max(x + width));
101        let (y0, y1) = (y.min(y + height), y.max(y + height));
102        let (w, h) = (x1 - x0, y1 - y0);
103        let (sheet_w, sheet_h) = (sheet.width(), sheet.height());
104        match self {
105            PageRotate::Rotate0 => (x0, y0, w, h),
106            PageRotate::Rotate90 => (y0, sheet_w - x1, h, w),
107            PageRotate::Rotate180 => (sheet_w - x1, sheet_h - y1, w, h),
108            PageRotate::Rotate270 => (sheet_h - y1, x0, h, w),
109        }
110    }
111
112    /// Turn a region rect given in the display frame's top-left space
113    /// (`[x1, y1, x2, y2]`, `y` down, on the rendered page) into the sheet
114    /// frame's top-left space, where the region readers work.
115    pub(crate) fn display_rect_to_sheet(self, sheet: &PageBox, rect: [f32; 4]) -> [f32; 4] {
116        let [ax, ay, bx, by] = rect;
117        let (x1, x2) = (ax.min(bx), ax.max(bx));
118        let (y1, y2) = (ay.min(by), ay.max(by));
119        let (display_w, display_h) = self.display_size(sheet);
120        match self {
121            PageRotate::Rotate0 => [x1, y1, x2, y2],
122            PageRotate::Rotate90 => [y1, display_w - x2, y2, display_w - x1],
123            PageRotate::Rotate180 => [
124                display_w - x2,
125                display_h - y2,
126                display_w - x1,
127                display_h - y1,
128            ],
129            PageRotate::Rotate270 => [display_h - y2, x1, display_h - y1, x2],
130        }
131    }
132}
133
134/// The page's inheritable `/Rotate`, walking `/Parent` links like the page
135/// boxes. The first ancestor carrying the key decides, as in renderers; a
136/// missing or malformed value reads as `0`.
137pub(crate) fn page_rotate(doc: &Document, page_id: ObjectId) -> PageRotate {
138    let mut id = page_id;
139    for _ in 0..32 {
140        let Ok(dict) = doc.get_dictionary(id) else {
141            return PageRotate::Rotate0;
142        };
143        if let Ok(value) = dict.get(b"Rotate") {
144            let value = match value {
145                Object::Reference(reference) => match doc.get_object(*reference) {
146                    Ok(object) => object,
147                    Err(_) => return PageRotate::Rotate0,
148                },
149                direct => direct,
150            };
151            return super::get_number(value)
152                .map(|degrees| PageRotate::from_degrees(degrees as f64))
153                .unwrap_or_default();
154        }
155        match dict.get(b"Parent") {
156            Ok(Object::Reference(parent)) => id = *parent,
157            _ => return PageRotate::Rotate0,
158        }
159    }
160    PageRotate::Rotate0
161}
162
163/// How one extracted page maps onto its rendered image: the page's `/Rotate`
164/// and the visible box it turns.
165#[derive(Debug, Clone, Copy, PartialEq)]
166pub(crate) struct DisplayPage {
167    pub(crate) rotate: PageRotate,
168    pub(crate) sheet: PageBox,
169}
170
171impl DisplayPage {
172    /// The display mapping of `page_id`, whose geometry was shifted into
173    /// `sheet` (the visible page box the extractor used).
174    pub(crate) fn new(doc: &Document, page_id: ObjectId, sheet: PageBox) -> DisplayPage {
175        DisplayPage {
176            rotate: page_rotate(doc, page_id),
177            sheet,
178        }
179    }
180
181    /// A region rect given on the rendered page (top-left space), expressed
182    /// in the sheet frame's top-left space.
183    pub(crate) fn region_to_sheet(&self, rect: [f32; 4]) -> [f32; 4] {
184        self.rotate.display_rect_to_sheet(&self.sheet, rect)
185    }
186}
187
188/// Move one page's items from the frame the extractor reports them in — the
189/// sheet frame, turned by `turn` when the page's text was predominantly
190/// rotated — into the display frame of a page rendered with `rotate`.
191/// Baseline angles follow: the dominant runs of a turned page read as `0`
192/// in the turned frame and, once the page is rendered the way it reads, as
193/// `0` in the display frame too. A page that is neither turned nor rotated
194/// is left untouched.
195pub(crate) fn items_to_display_frame(
196    items: &mut [TextItem],
197    turn: PageRotation,
198    rotate: PageRotate,
199    sheet: &PageBox,
200) {
201    if turn == PageRotation::Upright && rotate == PageRotate::Rotate0 {
202        return;
203    }
204    for item in items {
205        turn.unrotate_box(&mut item.x, &mut item.y, &mut item.width, &mut item.height);
206        let (x, y, width, height) =
207            rotate.sheet_box_to_display(sheet, item.x, item.y, item.width, item.height);
208        item.x = x;
209        item.y = y;
210        item.width = width;
211        item.height = height;
212        // Only text runs carry a baseline angle; placeholders keep the `0`
213        // they were extracted with (see `correct_rotated_page`).
214        if matches!(item.item_type, ItemType::Text) {
215            item.rotation = normalize_degrees(
216                item.rotation - turn.baseline_rebase_degrees() - rotate.degrees(),
217            );
218        }
219    }
220}
221
222/// [`items_to_display_frame`] for a whole document's extraction:
223/// `page_rotations` holds the turn of every predominantly rotated page
224/// (pages absent from it are upright), as the position pipeline reports it,
225/// and each page's `/Rotate` and visible box are read from `doc`.
226pub(crate) fn document_items_to_display_frame(
227    doc: &Document,
228    items: &mut [TextItem],
229    page_rotations: &HashMap<u32, PageRotation>,
230) {
231    let pages = doc.get_pages();
232    let mut frames: HashMap<u32, (PageRotation, PageRotate, PageBox)> = HashMap::new();
233    for item in items {
234        let (turn, rotate, sheet) = *frames.entry(item.page).or_insert_with(|| {
235            let turn = page_rotations
236                .get(&item.page)
237                .copied()
238                .unwrap_or(PageRotation::Upright);
239            // The same fallback the pipeline shifts into when a page has no
240            // usable box, so the display frame agrees with the sheet frame.
241            let (rotate, sheet) = match pages.get(&item.page) {
242                Some(&page_id) => (
243                    page_rotate(doc, page_id),
244                    visible_page_box(doc, page_id).unwrap_or(PageBox::LETTER),
245                ),
246                None => (PageRotate::Rotate0, PageBox::LETTER),
247            };
248            (turn, rotate, sheet)
249        });
250        items_to_display_frame(std::slice::from_mut(item), turn, rotate, &sheet);
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use lopdf::dictionary;
258
259    fn item(x: f32, y: f32, width: f32, height: f32, rotation: f32) -> TextItem {
260        TextItem {
261            text: "a".into(),
262            x,
263            y,
264            width,
265            height,
266            font: String::new(),
267            font_tag: String::new(),
268            legacy_symbol_rewrite: false,
269            font_size: 12.0,
270            page: 1,
271            is_bold: false,
272            is_italic: false,
273            font_weight: None,
274            bold_source: None,
275            fixed_pitch: None,
276            fill_color: None,
277            stroke_color: None,
278            render_mode: None,
279            is_underline: false,
280            is_strikeout: false,
281            item_type: ItemType::Text,
282            mcid: None,
283            baseline_shift: 0.0,
284            rotation,
285            advance_known: true,
286        }
287    }
288
289    fn geometry(item: &TextItem) -> (f32, f32, f32, f32, f32) {
290        (item.x, item.y, item.width, item.height, item.rotation)
291    }
292
293    /// One-page document with an optional `/Rotate` on the page and on the
294    /// `/Pages` node.
295    fn doc_with_rotate(page: Option<Object>, parent: Option<Object>) -> (Document, ObjectId) {
296        let mut doc = Document::with_version("1.5");
297        let pages_id = doc.new_object_id();
298        let mut page_dict = dictionary! {
299            "Type" => "Page",
300            "Parent" => Object::Reference(pages_id),
301            "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
302        };
303        if let Some(rotate) = page {
304            page_dict.set("Rotate", rotate);
305        }
306        let page_id = doc.add_object(page_dict);
307        let mut pages = dictionary! {
308            "Type" => "Pages",
309            "Count" => 1,
310            "Kids" => vec![Object::Reference(page_id)],
311        };
312        if let Some(rotate) = parent {
313            pages.set("Rotate", rotate);
314        }
315        doc.objects.insert(pages_id, pages.into());
316        let catalog_id = doc.add_object(dictionary! {
317            "Type" => "Catalog",
318            "Pages" => Object::Reference(pages_id),
319        });
320        doc.trailer.set("Root", Object::Reference(catalog_id));
321        (doc, page_id)
322    }
323
324    #[test]
325    fn rotate_snaps_to_a_right_angle_in_a_full_turn() {
326        use PageRotate::*;
327        for (degrees, expected) in [
328            (0.0, Rotate0),
329            (90.0, Rotate90),
330            (180.0, Rotate180),
331            (270.0, Rotate270),
332            (360.0, Rotate0),
333            (-90.0, Rotate270),
334            (-180.0, Rotate180),
335            (-270.0, Rotate90),
336            (450.0, Rotate90),
337            (-450.0, Rotate270),
338            (720.0, Rotate0),
339            (810.0, Rotate90),
340            (89.6, Rotate90),
341            (44.0, Rotate0),
342            (45.0, Rotate90),
343            (134.9, Rotate90),
344            (135.0, Rotate180),
345            (f64::NAN, Rotate0),
346            (f64::INFINITY, Rotate0),
347            (f64::NEG_INFINITY, Rotate0),
348        ] {
349            assert_eq!(PageRotate::from_degrees(degrees), expected, "{degrees}");
350        }
351    }
352
353    #[test]
354    fn rotate_is_read_from_the_page_and_normalised() {
355        for (value, expected) in [
356            (Object::Integer(90), PageRotate::Rotate90),
357            (Object::Real(270.0), PageRotate::Rotate270),
358            (Object::Integer(-90), PageRotate::Rotate270),
359            (Object::Integer(450), PageRotate::Rotate90),
360            (Object::Integer(180), PageRotate::Rotate180),
361        ] {
362            let (doc, page_id) = doc_with_rotate(Some(value.clone()), None);
363            assert_eq!(page_rotate(&doc, page_id), expected, "{value:?}");
364        }
365    }
366
367    #[test]
368    fn rotate_is_inherited_from_the_page_tree() {
369        let (doc, page_id) = doc_with_rotate(None, Some(Object::Integer(90)));
370        assert_eq!(page_rotate(&doc, page_id), PageRotate::Rotate90);
371        // The page's own value wins over the inherited one.
372        let (doc, page_id) = doc_with_rotate(Some(Object::Integer(180)), Some(Object::Integer(90)));
373        assert_eq!(page_rotate(&doc, page_id), PageRotate::Rotate180);
374    }
375
376    #[test]
377    fn missing_or_malformed_rotate_reads_as_zero() {
378        let (doc, page_id) = doc_with_rotate(None, None);
379        assert_eq!(page_rotate(&doc, page_id), PageRotate::Rotate0);
380        let (doc, page_id) = doc_with_rotate(Some(Object::Name(b"ninety".to_vec())), None);
381        assert_eq!(page_rotate(&doc, page_id), PageRotate::Rotate0);
382        // The first ancestor carrying the key decides, malformed or not.
383        let (doc, page_id) = doc_with_rotate(
384            Some(Object::Name(b"ninety".to_vec())),
385            Some(Object::Integer(90)),
386        );
387        assert_eq!(page_rotate(&doc, page_id), PageRotate::Rotate0);
388    }
389
390    #[test]
391    fn indirect_rotate_is_resolved() {
392        let (mut doc, page_id) = doc_with_rotate(None, None);
393        let rotate_id = doc.add_object(Object::Integer(270));
394        doc.get_dictionary_mut(page_id)
395            .unwrap()
396            .set("Rotate", Object::Reference(rotate_id));
397        assert_eq!(page_rotate(&doc, page_id), PageRotate::Rotate270);
398    }
399
400    #[test]
401    fn sheet_boxes_land_where_a_renderer_draws_them() {
402        let sheet = PageBox::LETTER;
403        let (x, y, w, h) = (100.0, 200.0, 50.0, 10.0);
404        assert_eq!(
405            PageRotate::Rotate0.sheet_box_to_display(&sheet, x, y, w, h),
406            (100.0, 200.0, 50.0, 10.0)
407        );
408        assert_eq!(
409            PageRotate::Rotate90.sheet_box_to_display(&sheet, x, y, w, h),
410            (200.0, 462.0, 10.0, 50.0)
411        );
412        assert_eq!(
413            PageRotate::Rotate180.sheet_box_to_display(&sheet, x, y, w, h),
414            (462.0, 582.0, 50.0, 10.0)
415        );
416        assert_eq!(
417            PageRotate::Rotate270.sheet_box_to_display(&sheet, x, y, w, h),
418            (582.0, 100.0, 10.0, 50.0)
419        );
420        // Negative extents describe the same box.
421        assert_eq!(
422            PageRotate::Rotate90.sheet_box_to_display(&sheet, 150.0, 210.0, -50.0, -10.0),
423            (200.0, 462.0, 10.0, 50.0)
424        );
425        assert_eq!(PageRotate::Rotate0.display_size(&sheet), (612.0, 792.0));
426        assert_eq!(PageRotate::Rotate90.display_size(&sheet), (792.0, 612.0));
427        assert_eq!(PageRotate::Rotate180.display_size(&sheet), (612.0, 792.0));
428        assert_eq!(PageRotate::Rotate270.display_size(&sheet), (792.0, 612.0));
429    }
430
431    /// The top-left rect of a y-up box on a page `height` tall.
432    fn top_left_rect(x: f32, y: f32, w: f32, h: f32, height: f32) -> [f32; 4] {
433        [x, height - y - h, x + w, height - y]
434    }
435
436    #[test]
437    fn display_rects_come_back_to_the_sheet_rect_of_the_same_box() {
438        for sheet in [
439            PageBox::LETTER,
440            PageBox::from_corners(50.0, 60.0, 350.0, 460.0).unwrap(),
441        ] {
442            let (x, y, w, h) = (20.0, 30.0, 40.0, 12.0);
443            let expected = top_left_rect(x, y, w, h, sheet.height());
444            for rotate in [
445                PageRotate::Rotate0,
446                PageRotate::Rotate90,
447                PageRotate::Rotate180,
448                PageRotate::Rotate270,
449            ] {
450                let (dx, dy, dw, dh) = rotate.sheet_box_to_display(&sheet, x, y, w, h);
451                let (_, display_h) = rotate.display_size(&sheet);
452                let display_rect = top_left_rect(dx, dy, dw, dh, display_h);
453                assert_eq!(
454                    rotate.display_rect_to_sheet(&sheet, display_rect),
455                    expected,
456                    "{rotate:?} on {sheet:?}"
457                );
458                // Corner order does not matter.
459                let [a, b, c, d] = display_rect;
460                assert_eq!(rotate.display_rect_to_sheet(&sheet, [c, d, a, b]), expected);
461            }
462        }
463    }
464
465    #[test]
466    fn display_rects_follow_the_documented_formulas() {
467        // Display page 792 x 612 for a quarter turn of a Letter sheet.
468        let sheet = PageBox::LETTER;
469        let rect = [200.0, 100.0, 210.0, 150.0];
470        assert_eq!(
471            PageRotate::Rotate90.display_rect_to_sheet(&sheet, rect),
472            [100.0, 792.0 - 210.0, 150.0, 792.0 - 200.0]
473        );
474        assert_eq!(
475            PageRotate::Rotate180.display_rect_to_sheet(&sheet, rect),
476            [612.0 - 210.0, 792.0 - 150.0, 612.0 - 200.0, 792.0 - 100.0]
477        );
478        assert_eq!(
479            PageRotate::Rotate270.display_rect_to_sheet(&sheet, rect),
480            [612.0 - 150.0, 200.0, 612.0 - 100.0, 210.0]
481        );
482        assert_eq!(
483            PageRotate::Rotate0.display_rect_to_sheet(&sheet, rect),
484            rect
485        );
486    }
487
488    #[test]
489    fn offset_crop_box_uses_the_visible_extents_only() {
490        // A 300 x 400 visible box whose origin is off (0, 0): the display
491        // frame depends on its extents, never on where it sits on the sheet.
492        let sheet = PageBox::from_corners(50.0, 60.0, 350.0, 460.0).unwrap();
493        assert_eq!(
494            PageRotate::Rotate90.sheet_box_to_display(&sheet, 20.0, 30.0, 40.0, 12.0),
495            (30.0, 240.0, 12.0, 40.0)
496        );
497        assert_eq!(PageRotate::Rotate90.display_size(&sheet), (400.0, 300.0));
498        assert_eq!(
499            PageRotate::Rotate180.sheet_box_to_display(&sheet, 20.0, 30.0, 40.0, 12.0),
500            (240.0, 358.0, 40.0, 12.0)
501        );
502    }
503
504    #[test]
505    fn turned_pages_are_unturned_before_the_display_turn() {
506        let sheet = PageBox::LETTER;
507        // A bottom-to-top run at `Tm [0 1 -1 0 40 420]`, 12pt, 200pt long:
508        // sheet box x ∈ [28, 40], y ∈ [420, 620], baseline angle 90. The
509        // extractor reports it in the counter-clockwise-turned frame.
510        let (mut x, mut y, mut w, mut h) = (28.0, 420.0, 12.0, 200.0);
511        PageRotation::Ccw.rotate_box(&mut x, &mut y, &mut w, &mut h);
512        assert_eq!((x, y, w, h), (420.0, -40.0, 200.0, 12.0));
513        let mut items = vec![item(x, y, w, h, 0.0)];
514        // Rendered under /Rotate 90 it is a horizontal line 420pt from the
515        // display page's left edge whose top sits 28pt (40 - h) below the
516        // top edge of the 792 x 612 display page.
517        items_to_display_frame(&mut items, PageRotation::Ccw, PageRotate::Rotate90, &sheet);
518        assert_eq!(geometry(&items[0]), (420.0, 572.0, 200.0, 12.0, 0.0));
519        assert_eq!(
520            PageRotate::Rotate90.sheet_box_to_display(&sheet, 28.0, 420.0, 12.0, 200.0),
521            (420.0, 572.0, 200.0, 12.0)
522        );
523
524        // An upright stray on that page (sheet box (72, 700, 100, 11),
525        // angle 0) reads as 270 in the turned frame and renders top-to-bottom.
526        let (mut x, mut y, mut w, mut h) = (72.0, 700.0, 100.0, 11.0);
527        PageRotation::Ccw.rotate_box(&mut x, &mut y, &mut w, &mut h);
528        let mut items = vec![item(x, y, w, h, 270.0)];
529        items_to_display_frame(&mut items, PageRotation::Ccw, PageRotate::Rotate90, &sheet);
530        assert_eq!(geometry(&items[0]), (700.0, 440.0, 11.0, 100.0, 270.0));
531
532        // A top-to-bottom run at `Tm [0 -1 1 0 300 700]`, 12pt, 200pt long:
533        // sheet box x ∈ [300, 312], y ∈ [500, 700], angle 270, reported in
534        // the clockwise-turned frame. /Rotate 270 renders it horizontally.
535        let (mut x, mut y, mut w, mut h) = (300.0, 500.0, 12.0, 200.0);
536        PageRotation::Cw.rotate_box(&mut x, &mut y, &mut w, &mut h);
537        assert_eq!((x, y, w, h), (-700.0, 300.0, 200.0, 12.0));
538        let mut items = vec![item(x, y, w, h, 0.0)];
539        items_to_display_frame(&mut items, PageRotation::Cw, PageRotate::Rotate270, &sheet);
540        assert_eq!(geometry(&items[0]), (92.0, 300.0, 200.0, 12.0, 0.0));
541
542        // Undoing the turn alone (an unrotated page) restores the sheet box.
543        let mut items = vec![item(x, y, w, h, 0.0)];
544        items_to_display_frame(&mut items, PageRotation::Cw, PageRotate::Rotate0, &sheet);
545        assert_eq!(geometry(&items[0]), (300.0, 500.0, 12.0, 200.0, 270.0));
546
547        // Placeholders turn with the page but never gain a baseline angle.
548        let mut placeholder = item(-700.0, 300.0, 200.0, 12.0, 0.0);
549        placeholder.item_type = ItemType::Image;
550        let mut items = vec![placeholder];
551        items_to_display_frame(&mut items, PageRotation::Cw, PageRotate::Rotate270, &sheet);
552        assert_eq!(geometry(&items[0]), (92.0, 300.0, 200.0, 12.0, 0.0));
553    }
554
555    #[test]
556    fn every_turn_and_rotate_pair_unturns_then_turns_with_the_page() {
557        let sheet = PageBox::LETTER;
558        // Sheet boxes with their baseline angle, each on a page turned the
559        // way its text votes: upright text on an unturned page, a
560        // bottom-to-top run on a counter-clockwise-turned one, a
561        // top-to-bottom run on a clockwise-turned one, and an upright stray
562        // on each turned page.
563        let cases = [
564            (PageRotation::Upright, (72.0, 700.0, 100.0, 11.0), 0.0),
565            (PageRotation::Ccw, (28.0, 420.0, 12.0, 200.0), 90.0),
566            (PageRotation::Ccw, (72.0, 700.0, 100.0, 11.0), 0.0),
567            (PageRotation::Cw, (300.0, 500.0, 12.0, 200.0), 270.0),
568            (PageRotation::Cw, (72.0, 700.0, 100.0, 11.0), 0.0),
569        ];
570        let rotates = [
571            PageRotate::Rotate0,
572            PageRotate::Rotate90,
573            PageRotate::Rotate180,
574            PageRotate::Rotate270,
575        ];
576        for (turn, (x, y, w, h), sheet_rotation) in cases {
577            for rotate in rotates {
578                // The item as the extractor reports it: box and baseline
579                // angle turned with the page.
580                let (mut tx, mut ty, mut tw, mut th) = (x, y, w, h);
581                turn.rotate_box(&mut tx, &mut ty, &mut tw, &mut th);
582                let reported = normalize_degrees(sheet_rotation + turn.baseline_rebase_degrees());
583                let mut items = vec![item(tx, ty, tw, th, reported)];
584                items_to_display_frame(&mut items, turn, rotate, &sheet);
585                // Undoing the turn gives the sheet box back, and the display
586                // turn is then the plain sheet-to-display mapping.
587                let (dx, dy, dw, dh) = rotate.sheet_box_to_display(&sheet, x, y, w, h);
588                let display_rotation = normalize_degrees(sheet_rotation - rotate.degrees());
589                assert_eq!(
590                    geometry(&items[0]),
591                    (dx, dy, dw, dh, display_rotation),
592                    "{turn:?} under {rotate:?}"
593                );
594            }
595        }
596    }
597
598    #[test]
599    fn upright_text_turns_with_the_page() {
600        let sheet = PageBox::LETTER;
601        for (rotate, expected) in [
602            (PageRotate::Rotate90, (700.0, 440.0, 11.0, 100.0, 270.0)),
603            (PageRotate::Rotate180, (440.0, 81.0, 100.0, 11.0, 180.0)),
604            (PageRotate::Rotate270, (81.0, 72.0, 11.0, 100.0, 90.0)),
605        ] {
606            let mut items = vec![item(72.0, 700.0, 100.0, 11.0, 0.0)];
607            items_to_display_frame(&mut items, PageRotation::Upright, rotate, &sheet);
608            assert_eq!(geometry(&items[0]), expected, "{rotate:?}");
609        }
610    }
611
612    #[test]
613    fn untouched_when_neither_turned_nor_rotated() {
614        // Not even extents are normalised: the display frame of an unrotated
615        // upright page is the sheet frame, byte for byte.
616        let mut items = vec![item(150.0, 210.0, -50.0, -10.0, 0.0)];
617        items_to_display_frame(
618            &mut items,
619            PageRotation::Upright,
620            PageRotate::Rotate0,
621            &PageBox::LETTER,
622        );
623        assert_eq!(geometry(&items[0]), (150.0, 210.0, -50.0, -10.0, 0.0));
624    }
625
626    #[test]
627    fn document_items_follow_their_own_page() {
628        // Page 1 carries /Rotate 90, page 2 nothing; the visible box of
629        // page 1 is an offset CropBox so its extents, not the MediaBox's,
630        // must drive the turn.
631        let mut doc = Document::with_version("1.5");
632        let pages_id = doc.new_object_id();
633        let page1 = doc.add_object(dictionary! {
634            "Type" => "Page",
635            "Parent" => Object::Reference(pages_id),
636            "MediaBox" => Object::Array(vec![0.into(), 0.into(), 400.into(), 500.into()]),
637            "CropBox" => Object::Array(vec![50.into(), 60.into(), 350.into(), 460.into()]),
638            "Rotate" => Object::Integer(90),
639        });
640        let page2 = doc.add_object(dictionary! {
641            "Type" => "Page",
642            "Parent" => Object::Reference(pages_id),
643            "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
644        });
645        doc.objects.insert(
646            pages_id,
647            dictionary! {
648                "Type" => "Pages",
649                "Count" => 2,
650                "Kids" => vec![Object::Reference(page1), Object::Reference(page2)],
651            }
652            .into(),
653        );
654        let catalog_id = doc.add_object(dictionary! {
655            "Type" => "Catalog",
656            "Pages" => Object::Reference(pages_id),
657        });
658        doc.trailer.set("Root", Object::Reference(catalog_id));
659
660        let mut first = item(20.0, 30.0, 40.0, 12.0, 0.0);
661        first.page = 1;
662        let mut second = item(20.0, 30.0, 40.0, 12.0, 0.0);
663        second.page = 2;
664        let mut items = vec![first, second];
665        document_items_to_display_frame(&doc, &mut items, &HashMap::new());
666        assert_eq!(geometry(&items[0]), (30.0, 240.0, 12.0, 40.0, 270.0));
667        assert_eq!(geometry(&items[1]), (20.0, 30.0, 40.0, 12.0, 0.0));
668
669        // A turned page 2 is unturned even though it is not rotated.
670        let mut turned = item(30.0, -60.0, 12.0, 40.0, 0.0);
671        turned.page = 2;
672        let mut items = vec![turned];
673        let rotations = HashMap::from([(2, PageRotation::Ccw)]);
674        document_items_to_display_frame(&doc, &mut items, &rotations);
675        assert_eq!(geometry(&items[0]), (20.0, 30.0, 40.0, 12.0, 90.0));
676    }
677}