Skip to main content

stet_pdf_reader/
page_boxes.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Per-page geometry: the five PDF page boxes plus rotation, user
6//! unit, and presentation hints.
7//!
8//! [`PageBoxes`] exposes [`PageInfo`]'s already-resolved `MediaBox`
9//! and `CropBox` plus the page-only `BleedBox`, `TrimBox`, `ArtBox`,
10//! `UserUnit`, `Dur`, `Trans`, and `AA` entries.
11//!
12//! Per ISO 32000-2 §14.8.2, MediaBox and CropBox are inheritable from
13//! the page tree; the bleed, trim, and art boxes are page-local.
14//! `parse_page_boxes` reads PageInfo for the inheritable fields and
15//! re-resolves the page dict for the page-local entries.
16
17use crate::objects::PdfObj;
18use crate::page_tree::PageInfo;
19use crate::resolver::Resolver;
20
21/// Page geometry and presentation hints, drawn from the page dict
22/// plus the inherited MediaBox/CropBox already resolved on
23/// [`PageInfo`].
24///
25/// All optional boxes (`crop_box`, `bleed_box`, `trim_box`,
26/// `art_box`) default to `MediaBox` per spec when absent. We expose
27/// them as `Option<[f64; 4]>` so callers can distinguish
28/// "explicitly set" from "spec-default fallback" — `Some` means the
29/// box was declared in the page dict (or inherited, for crop_box);
30/// `None` means it was never declared and the consumer should use
31/// `media_box` as the fallback.
32#[derive(Debug, Clone, PartialEq)]
33pub struct PageBoxes {
34    /// `/MediaBox` — required; defines the boundaries of the
35    /// physical medium.
36    pub media_box: [f64; 4],
37    /// `/CropBox` — visible area; `None` means not declared (use
38    /// `media_box`). Inherited from the page tree if present on a
39    /// parent.
40    pub crop_box: Option<[f64; 4]>,
41    /// `/BleedBox` — bounds of the area within which page contents
42    /// may bleed when output in production.
43    pub bleed_box: Option<[f64; 4]>,
44    /// `/TrimBox` — intended dimensions of the finished page after
45    /// trimming.
46    pub trim_box: Option<[f64; 4]>,
47    /// `/ArtBox` — extent of the page's meaningful content.
48    pub art_box: Option<[f64; 4]>,
49    /// `/Rotate` — clockwise rotation in degrees (multiple of 90).
50    pub rotate: u16,
51    /// `/UserUnit` — multiplier for default user-space units.
52    /// Default `1.0` per spec.
53    pub user_unit: f64,
54    /// `/Dur` — page display duration for presentation mode.
55    pub duration: Option<f64>,
56    /// `/Trans` — page-transition dict presence.
57    pub has_transition: bool,
58    /// `/AA` — additional-actions dict presence.
59    pub has_additional_actions: bool,
60}
61
62/// Read the page-box and presentation-hint entries for a page.
63///
64/// `pages` is the document's page list (usually `PdfDocument::pages()`);
65/// `page_index` is the 0-based page number.
66///
67/// The inherited `MediaBox` and `CropBox` come from [`PageInfo`] (the
68/// page-tree walker resolved inheritance at document-load time). The
69/// page-local `BleedBox`, `TrimBox`, `ArtBox`, `UserUnit`, `Dur`,
70/// `Trans`, and `AA` are read fresh from the page dict via the
71/// resolver.
72///
73/// Returns `None` if `page_index` is out of range; otherwise always
74/// returns a populated value (missing entries default per spec).
75pub fn parse_page_boxes(
76    resolver: &Resolver,
77    pages: &[PageInfo],
78    page_index: usize,
79) -> Option<PageBoxes> {
80    let info = pages.get(page_index)?;
81
82    let mut boxes = PageBoxes {
83        media_box: info.media_box,
84        crop_box: None,
85        bleed_box: None,
86        trim_box: None,
87        art_box: None,
88        rotate: rotate_from_info(info.rotate),
89        user_unit: 1.0,
90        duration: None,
91        has_transition: false,
92        has_additional_actions: false,
93    };
94
95    // /CropBox: PageInfo resolves it via inheritance and falls back
96    // to MediaBox. Distinguishing explicit-vs-default requires a
97    // direct read of the page dict and walking parents for the
98    // inheritance chain. For now, treat `info.crop_box != info.media_box`
99    // as a strong proxy for "explicitly set"; a stricter check would
100    // walk the page-tree chain for /CropBox presence. Real PDFs
101    // typically set crop_box explicitly when they want it different,
102    // so this approximation matches common behaviour.
103    if info.crop_box != info.media_box {
104        boxes.crop_box = Some(info.crop_box);
105    }
106
107    // Read the page dict for page-local entries.
108    if let Ok(obj) = resolver.resolve(info.obj_num, 0)
109        && let Some(dict) = obj.as_dict()
110    {
111        boxes.bleed_box = dict.get_array(b"BleedBox").and_then(parse_box);
112        boxes.trim_box = dict.get_array(b"TrimBox").and_then(parse_box);
113        boxes.art_box = dict.get_array(b"ArtBox").and_then(parse_box);
114        // CropBox: prefer an explicit page-level entry over the
115        // proxy guess above.
116        if let Some(cb) = dict.get_array(b"CropBox").and_then(parse_box) {
117            boxes.crop_box = Some(cb);
118        }
119        if let Some(uu) = dict.get_f64(b"UserUnit")
120            && uu > 0.0
121        {
122            boxes.user_unit = uu;
123        }
124        boxes.duration = dict.get_f64(b"Dur");
125        boxes.has_transition = dict.get(b"Trans").is_some();
126        boxes.has_additional_actions = dict.get(b"AA").is_some();
127    }
128
129    Some(boxes)
130}
131
132fn parse_box(arr: &[PdfObj]) -> Option<[f64; 4]> {
133    if arr.len() < 4 {
134        return None;
135    }
136    Some([
137        arr[0].as_f64()?,
138        arr[1].as_f64()?,
139        arr[2].as_f64()?,
140        arr[3].as_f64()?,
141    ])
142}
143
144fn rotate_from_info(rot: i32) -> u16 {
145    let mut r = rot.rem_euclid(360);
146    if r % 90 != 0 {
147        r = 0;
148    }
149    r as u16
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn rotate_normalizes_negative() {
158        assert_eq!(rotate_from_info(-90), 270);
159        assert_eq!(rotate_from_info(0), 0);
160        assert_eq!(rotate_from_info(90), 90);
161        assert_eq!(rotate_from_info(360), 0);
162        assert_eq!(rotate_from_info(450), 90);
163    }
164
165    #[test]
166    fn rotate_drops_non_multiples_of_90() {
167        // PDFs sometimes ship invalid /Rotate values; we coerce to 0
168        // rather than propagate.
169        assert_eq!(rotate_from_info(45), 0);
170        assert_eq!(rotate_from_info(180 + 1), 0);
171    }
172
173    #[test]
174    fn parse_box_requires_four_numbers() {
175        let arr = vec![PdfObj::Real(0.0), PdfObj::Real(0.0), PdfObj::Real(100.0)];
176        assert!(parse_box(&arr).is_none());
177        let arr = vec![
178            PdfObj::Int(0),
179            PdfObj::Int(0),
180            PdfObj::Int(612),
181            PdfObj::Int(792),
182        ];
183        assert_eq!(parse_box(&arr), Some([0.0, 0.0, 612.0, 792.0]));
184    }
185
186    /// `parse_box` should accept mixed Int/Real entries (PDFs often
187    /// emit integer literals for whole-number coordinates).
188    #[test]
189    fn parse_box_mixed_int_real() {
190        let arr = vec![
191            PdfObj::Int(0),
192            PdfObj::Real(0.5),
193            PdfObj::Int(612),
194            PdfObj::Real(792.0),
195        ];
196        assert_eq!(parse_box(&arr), Some([0.0, 0.5, 612.0, 792.0]));
197    }
198
199    #[test]
200    fn parse_box_rejects_non_numeric() {
201        let arr = vec![
202            PdfObj::Int(0),
203            PdfObj::Int(0),
204            PdfObj::Name(b"oops".to_vec()),
205            PdfObj::Int(792),
206        ];
207        // Silent failure for the third entry → None.
208        assert!(parse_box(&arr).is_none());
209    }
210}