1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3
4use tracing::warn;
5use zpdf_core::{ObjectId, PdfDict, PdfObject, Rect, Result};
6use zpdf_parser::PdfFile;
7
8pub(crate) const MAX_PAGE_TREE_DEPTH: usize = 64;
12
13const DEFAULT_MEDIA_BOX: Rect = Rect {
16 x0: 0.0,
17 y0: 0.0,
18 x1: 612.0,
19 y1: 792.0,
20};
21
22fn is_usable_box(r: &Rect) -> bool {
26 if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
27 return false;
28 }
29 let n = r.normalize();
30 n.width() > 0.0 && n.height() > 0.0
31}
32
33#[derive(Debug)]
34pub struct PdfPage {
35 pub id: ObjectId,
36 pub media_box: Rect,
37 pub crop_box: Rect,
38 pub rotate: i32,
39 pub resources: ResourceDict,
40 pub contents: Vec<ObjectId>,
41 pub annots: Vec<ObjectId>,
43 pub output_intents: Vec<crate::output_intents::OutputIntent>,
47}
48
49#[derive(Debug, Default)]
50pub struct ResourceDict {
51 pub fonts: HashMap<String, ObjectId>,
52 pub xobjects: HashMap<String, ObjectId>,
53 pub ext_g_state: HashMap<String, ObjectId>,
54 pub ext_g_state_inline: HashMap<String, zpdf_core::PdfDict>,
55 pub color_spaces: HashMap<String, ObjectId>,
56 pub color_spaces_inline: HashMap<String, PdfObject>,
59 pub patterns: HashMap<String, ObjectId>,
60 pub shadings: HashMap<String, ObjectId>,
61 pub shadings_inline: HashMap<String, PdfObject>,
62 pub properties: HashMap<String, ObjectId>,
64 pub properties_inline: HashMap<String, zpdf_core::PdfDict>,
65}
66
67impl PdfPage {
68 pub fn from_object(file: &PdfFile, page_id: ObjectId) -> Result<Self> {
69 let obj = file.resolve(page_id)?;
70 let dict = obj.as_dict()?;
71
72 let inherited = InheritedAttrs::gather(file, dict);
76
77 let media_box = inherited
82 .media_box
83 .filter(is_usable_box)
84 .unwrap_or(DEFAULT_MEDIA_BOX);
85 let crop_box = inherited
86 .crop_box
87 .filter(is_usable_box)
88 .unwrap_or(media_box);
89 let rotate = inherited.rotate.unwrap_or(0);
90 let resources = inherited.resources.unwrap_or_default();
91
92 let contents = Self::collect_content_refs(file, dict.get("Contents"));
93 let annots = Self::collect_annot_refs(file, dict.get("Annots"));
94 let output_intents = crate::output_intents::parse_page_output_intents(file, dict);
96
97 Ok(Self {
98 id: page_id,
99 media_box,
100 crop_box,
101 rotate,
102 resources,
103 contents,
104 annots,
105 output_intents,
106 })
107 }
108
109 fn collect_content_refs(file: &PdfFile, contents: Option<&PdfObject>) -> Vec<ObjectId> {
115 fn refs_from_array(arr: &[PdfObject]) -> Vec<ObjectId> {
116 arr.iter()
117 .filter_map(|o| match o {
118 PdfObject::Ref(r) => Some(*r),
119 _ => None,
120 })
121 .collect()
122 }
123 match contents {
124 Some(PdfObject::Array(arr)) => refs_from_array(arr),
125 Some(PdfObject::Ref(r)) => match file.resolve(*r) {
126 Ok(PdfObject::Array(arr)) => refs_from_array(&arr),
128 Ok(PdfObject::Stream(_)) => vec![*r],
130 _ => vec![*r],
133 },
134 _ => vec![],
135 }
136 }
137
138 fn collect_annot_refs(file: &PdfFile, annots: Option<&PdfObject>) -> Vec<ObjectId> {
141 fn refs_from_array(arr: &[PdfObject]) -> Vec<ObjectId> {
142 arr.iter()
143 .filter_map(|o| match o {
144 PdfObject::Ref(r) => Some(*r),
145 _ => None,
146 })
147 .collect()
148 }
149 match annots {
150 Some(PdfObject::Array(arr)) => refs_from_array(arr),
151 Some(PdfObject::Ref(r)) => match file.resolve(*r) {
152 Ok(PdfObject::Array(arr)) => refs_from_array(&arr),
153 _ => Vec::new(),
154 },
155 _ => Vec::new(),
156 }
157 }
158
159 pub fn width(&self) -> f64 {
160 self.media_box.width()
161 }
162
163 pub fn height(&self) -> f64 {
164 self.media_box.height()
165 }
166
167 pub fn effective_box(&self) -> Rect {
172 let media = self.media_box.normalize();
173 let crop = self.crop_box.normalize();
174 let inter = Rect::new(
175 crop.x0.max(media.x0),
176 crop.y0.max(media.y0),
177 crop.x1.min(media.x1),
178 crop.y1.min(media.y1),
179 );
180 if inter.x1 > inter.x0 && inter.y1 > inter.y0 {
181 inter
182 } else {
183 media
184 }
185 }
186}
187
188#[derive(Default)]
191struct InheritedAttrs {
192 media_box: Option<Rect>,
193 crop_box: Option<Rect>,
194 rotate: Option<i32>,
195 resources: Option<ResourceDict>,
196}
197
198impl InheritedAttrs {
199 fn is_complete(&self) -> bool {
200 self.media_box.is_some()
201 && self.crop_box.is_some()
202 && self.rotate.is_some()
203 && self.resources.is_some()
204 }
205
206 fn gather(file: &PdfFile, leaf: &PdfDict) -> Self {
207 let mut attrs = Self::default();
208 let mut visited: HashSet<ObjectId> = HashSet::new();
209 let mut current: Cow<'_, PdfDict> = Cow::Borrowed(leaf);
210 let mut depth = 0usize;
211
212 loop {
213 attrs.absorb(file, ¤t);
214 if attrs.is_complete() {
215 break;
216 }
217 let parent_ref = match current.get("Parent") {
218 Some(PdfObject::Ref(r)) => *r,
219 _ => break,
220 };
221 depth += 1;
222 if depth > MAX_PAGE_TREE_DEPTH {
223 warn!("page-tree /Parent chain deeper than {MAX_PAGE_TREE_DEPTH}; stopping inheritance walk");
224 break;
225 }
226 if !visited.insert(parent_ref) {
227 warn!("page-tree /Parent cycle at {parent_ref}; stopping inheritance walk");
228 break;
229 }
230 match file.resolve(parent_ref) {
231 Ok(PdfObject::Dict(d)) => current = Cow::Owned(d),
232 Ok(PdfObject::Null) => {
233 warn!(
234 "page-tree parent {parent_ref} resolves to null; stopping inheritance walk"
235 );
236 break;
237 }
238 Ok(other) => {
239 warn!(
240 "page-tree parent {parent_ref} is {}, expected Dict; stopping inheritance walk",
241 other.type_name()
242 );
243 break;
244 }
245 Err(e) => {
246 warn!("failed to resolve page-tree parent {parent_ref}: {e}");
247 break;
248 }
249 }
250 }
251 attrs
252 }
253
254 fn absorb(&mut self, file: &PdfFile, dict: &PdfDict) {
257 if self.media_box.is_none() {
258 self.media_box = resolve_rect(file, dict, "MediaBox");
259 }
260 if self.crop_box.is_none() {
261 self.crop_box = resolve_rect(file, dict, "CropBox");
262 }
263 if self.rotate.is_none() {
264 self.rotate = resolve_i64(file, dict.get("Rotate")).map(|n| n as i32);
265 }
266 if self.resources.is_none() {
267 if let Some(d) = resolve_sub_dict(dict, "Resources", file) {
268 match parse_resource_dict(&d, file) {
269 Ok(r) => self.resources = Some(r),
270 Err(e) => warn!("failed to parse /Resources: {e}"),
271 }
272 }
273 }
274 }
275}
276
277pub(crate) fn resolve_rect(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<Rect> {
280 let arr: Cow<'_, [PdfObject]> = match dict.get(key)? {
281 PdfObject::Array(a) => Cow::Borrowed(a.as_slice()),
282 PdfObject::Ref(r) => match file.resolve(*r) {
283 Ok(PdfObject::Array(a)) => Cow::Owned(a),
284 Ok(other) => {
285 warn!(
286 "/{key} ref {r} resolved to {}, expected Array",
287 other.type_name()
288 );
289 return None;
290 }
291 Err(e) => {
292 warn!("failed to resolve /{key} ref {r}: {e}");
293 return None;
294 }
295 },
296 _ => return None,
297 };
298 if arr.len() != 4 {
299 warn!("/{key} array has {} elements, expected 4", arr.len());
300 return None;
301 }
302 let mut v = [0f64; 4];
303 for (slot, obj) in v.iter_mut().zip(arr.iter()) {
304 *slot = match obj {
305 PdfObject::Ref(r) => file.resolve(*r).ok()?.as_f64().ok()?,
306 other => other.as_f64().ok()?,
307 };
308 }
309 Some(Rect::new(v[0], v[1], v[2], v[3]))
310}
311
312fn resolve_i64(file: &PdfFile, value: Option<&PdfObject>) -> Option<i64> {
314 match value? {
315 PdfObject::Integer(n) => Some(*n),
316 PdfObject::Real(r) => Some(*r as i64),
317 PdfObject::Ref(r) => match file.resolve(*r).ok()? {
318 PdfObject::Integer(n) => Some(n),
319 PdfObject::Real(r) => Some(r as i64),
320 _ => None,
321 },
322 _ => None,
323 }
324}
325
326fn resolve_sub_dict<'a>(
327 dict: &'a zpdf_core::PdfDict,
328 key: &str,
329 file: &'a PdfFile,
330) -> Option<std::borrow::Cow<'a, zpdf_core::PdfDict>> {
331 match dict.get(key) {
332 Some(PdfObject::Dict(d)) => Some(std::borrow::Cow::Borrowed(d)),
333 Some(PdfObject::Ref(r)) => file.resolve(*r).ok().and_then(|o| match o {
334 PdfObject::Dict(d) => Some(std::borrow::Cow::Owned(d)),
335 _ => None,
336 }),
337 _ => None,
338 }
339}
340
341pub fn parse_resource_dict(dict: &zpdf_core::PdfDict, file: &PdfFile) -> Result<ResourceDict> {
342 let mut res = ResourceDict::default();
343
344 if let Some(fonts) = resolve_sub_dict(dict, "Font", file) {
345 for (name, obj) in &fonts.0 {
346 if let PdfObject::Ref(r) = obj {
347 res.fonts.insert(name.0.clone(), *r);
348 }
349 }
350 }
351
352 if let Some(xobjects) = resolve_sub_dict(dict, "XObject", file) {
353 for (name, obj) in &xobjects.0 {
354 if let PdfObject::Ref(r) = obj {
355 res.xobjects.insert(name.0.clone(), *r);
356 }
357 }
358 }
359
360 if let Some(gs) = resolve_sub_dict(dict, "ExtGState", file) {
361 for (name, obj) in &gs.0 {
362 match obj {
363 PdfObject::Ref(r) => {
364 res.ext_g_state.insert(name.0.clone(), *r);
365 }
366 PdfObject::Dict(d) => {
367 res.ext_g_state_inline.insert(name.0.clone(), d.clone());
368 }
369 _ => {}
370 }
371 }
372 }
373
374 if let Some(cs) = resolve_sub_dict(dict, "ColorSpace", file) {
375 for (name, obj) in &cs.0 {
376 match obj {
377 PdfObject::Ref(r) => {
378 res.color_spaces.insert(name.0.clone(), *r);
379 }
380 other @ (PdfObject::Array(_) | PdfObject::Name(_)) => {
381 res.color_spaces_inline
382 .insert(name.0.clone(), other.clone());
383 }
384 _ => {}
385 }
386 }
387 }
388
389 if let Some(pat) = resolve_sub_dict(dict, "Pattern", file) {
390 for (name, obj) in &pat.0 {
391 if let PdfObject::Ref(r) = obj {
392 res.patterns.insert(name.0.clone(), *r);
393 }
394 }
395 }
396
397 if let Some(sh) = resolve_sub_dict(dict, "Shading", file) {
398 for (name, obj) in &sh.0 {
399 match obj {
400 PdfObject::Ref(r) => {
401 res.shadings.insert(name.0.clone(), *r);
402 }
403 other @ PdfObject::Dict(_) => {
404 res.shadings_inline.insert(name.0.clone(), other.clone());
405 }
406 _ => {}
407 }
408 }
409 }
410
411 if let Some(props) = resolve_sub_dict(dict, "Properties", file) {
412 for (name, obj) in &props.0 {
413 match obj {
414 PdfObject::Ref(r) => {
415 res.properties.insert(name.0.clone(), *r);
416 }
417 PdfObject::Dict(d) => {
418 res.properties_inline.insert(name.0.clone(), d.clone());
419 }
420 _ => {}
421 }
422 }
423 }
424
425 Ok(res)
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::test_util::build_pdf;
432 use crate::PdfDocument;
433
434 fn page0(objects: &[&str]) -> PdfPage {
436 let doc = PdfDocument::open(build_pdf(objects)).expect("open");
437 doc.page(0).expect("page")
438 }
439
440 #[test]
441 fn rotate_and_resources_inherited_from_pages_node() {
442 let page = page0(&[
443 "<< /Type /Catalog /Pages 2 0 R >>",
444 "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Rotate 90 /Resources << /Font << /F1 4 0 R >> >> >>",
445 "<< /Type /Page /Parent 2 0 R >>",
446 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
447 ]);
448 assert_eq!(page.rotate, 90);
449 assert_eq!(page.media_box, Rect::new(0.0, 0.0, 612.0, 792.0));
450 assert_eq!(page.resources.fonts.get("F1"), Some(&ObjectId(4, 0)));
451 }
452
453 #[test]
454 fn leaf_attributes_override_inherited() {
455 let page = page0(&[
456 "<< /Type /Catalog /Pages 2 0 R >>",
457 "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Rotate 90 /Resources << /Font << /F1 4 0 R >> >> >>",
458 "<< /Type /Page /Parent 2 0 R /Rotate 180 /Resources << /Font << /F2 4 0 R >> >> >>",
459 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
460 ]);
461 assert_eq!(page.rotate, 180);
462 assert!(page.resources.fonts.contains_key("F2"));
463 assert!(!page.resources.fonts.contains_key("F1"));
465 }
466
467 #[test]
468 fn indirect_media_and_crop_boxes_resolve() {
469 let page = page0(&[
470 "<< /Type /Catalog /Pages 2 0 R >>",
471 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
472 "<< /Type /Page /Parent 2 0 R /MediaBox 4 0 R /CropBox [10 10 5 0 R 200] >>",
473 "[0 0 300 400]",
474 "100",
475 ]);
476 assert_eq!(page.media_box, Rect::new(0.0, 0.0, 300.0, 400.0));
477 assert_eq!(page.crop_box, Rect::new(10.0, 10.0, 100.0, 200.0));
478 }
479
480 #[test]
481 fn parent_cycle_terminates_and_keeps_found_values() {
482 let page = page0(&[
485 "<< /Type /Catalog /Pages 2 0 R >>",
486 "<< /Type /Pages /Kids [3 0 R] /Count 1 /Parent 3 0 R /MediaBox [0 0 100 100] >>",
487 "<< /Type /Page /Parent 2 0 R >>",
488 ]);
489 assert_eq!(page.media_box, Rect::new(0.0, 0.0, 100.0, 100.0));
490 assert_eq!(page.rotate, 0);
491 }
492
493 #[test]
494 fn annots_refs_collected() {
495 let page = page0(&[
496 "<< /Type /Catalog /Pages 2 0 R >>",
497 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
498 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R 5 0 R] >>",
499 "<< /Type /Annot /Subtype /Link >>",
500 "<< /Type /Annot /Subtype /Square >>",
501 ]);
502 assert_eq!(page.annots, vec![ObjectId(4, 0), ObjectId(5, 0)]);
503 }
504
505 fn page_with_boxes(media: Rect, crop: Rect) -> PdfPage {
506 PdfPage {
507 id: ObjectId(1, 0),
508 media_box: media,
509 crop_box: crop,
510 rotate: 0,
511 resources: ResourceDict::default(),
512 contents: vec![],
513 annots: vec![],
514 output_intents: vec![],
515 }
516 }
517
518 #[test]
519 fn effective_box_intersects_crop_with_media() {
520 let media = Rect::new(0.0, 0.0, 612.0, 792.0);
521 let p = page_with_boxes(media, Rect::new(10.0, 20.0, 500.0, 700.0));
523 assert_eq!(p.effective_box(), Rect::new(10.0, 20.0, 500.0, 700.0));
524 let p = page_with_boxes(media, Rect::new(-50.0, -50.0, 700.0, 800.0));
526 assert_eq!(p.effective_box(), media);
527 let p = page_with_boxes(media, Rect::new(300.0, 400.0, 900.0, 900.0));
529 assert_eq!(p.effective_box(), Rect::new(300.0, 400.0, 612.0, 792.0));
530 }
531
532 #[test]
533 fn effective_box_falls_back_to_media_box() {
534 let media = Rect::new(0.0, 0.0, 612.0, 792.0);
535 let p = page_with_boxes(media, Rect::new(1000.0, 1000.0, 1100.0, 1100.0));
537 assert_eq!(p.effective_box(), media);
538 let p = page_with_boxes(media, Rect::new(100.0, 100.0, 100.0, 100.0));
540 assert_eq!(p.effective_box(), media);
541 let p = page_with_boxes(media, media);
543 assert_eq!(p.effective_box(), media);
544 }
545
546 #[test]
547 fn effective_box_normalizes_inverted_crop() {
548 let media = Rect::new(0.0, 0.0, 612.0, 792.0);
549 let p = page_with_boxes(media, Rect::new(500.0, 700.0, 10.0, 20.0));
550 assert_eq!(p.effective_box(), Rect::new(10.0, 20.0, 500.0, 700.0));
551 }
552}