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