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