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}