Skip to main content

folio_doc/
page.rs

1//! Page — represents a single PDF page.
2
3use folio_core::{Matrix2D, Rect};
4use folio_cos::{ObjectId, PdfObject};
5
6/// Represents a single PDF page.
7#[derive(Debug, Clone)]
8pub struct Page {
9    /// The object ID of this page.
10    id: ObjectId,
11    /// The page dictionary.
12    dict: PdfObject,
13    /// 1-based page number.
14    page_num: u32,
15}
16
17/// Page rotation values.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Rotation {
20    None = 0,
21    Rotate90 = 90,
22    Rotate180 = 180,
23    Rotate270 = 270,
24}
25
26impl Rotation {
27    pub fn from_degrees(degrees: i64) -> Self {
28        match ((degrees % 360) + 360) % 360 {
29            90 => Rotation::Rotate90,
30            180 => Rotation::Rotate180,
31            270 => Rotation::Rotate270,
32            _ => Rotation::None,
33        }
34    }
35
36    pub fn degrees(&self) -> i32 {
37        *self as i32
38    }
39}
40
41impl Page {
42    pub(crate) fn new(id: ObjectId, dict: PdfObject, page_num: u32) -> Self {
43        Self { id, dict, page_num }
44    }
45
46    /// Get the object ID of this page.
47    pub fn id(&self) -> ObjectId {
48        self.id
49    }
50
51    /// Get the 1-based page number.
52    pub fn page_num(&self) -> u32 {
53        self.page_num
54    }
55
56    /// Get the raw page dictionary.
57    pub fn dict(&self) -> &PdfObject {
58        &self.dict
59    }
60
61    /// Extract a Rect from an array of 4 numbers.
62    fn get_rect(&self, key: &[u8]) -> Option<Rect> {
63        let arr = self.dict.dict_get(key)?.as_array()?;
64        if arr.len() >= 4 {
65            Some(Rect::new(
66                arr[0].as_f64()?,
67                arr[1].as_f64()?,
68                arr[2].as_f64()?,
69                arr[3].as_f64()?,
70            ))
71        } else {
72            None
73        }
74    }
75
76    /// Get the media box (required for all pages).
77    pub fn media_box(&self) -> Rect {
78        self.get_rect(b"MediaBox")
79            .unwrap_or_else(|| Rect::new(0.0, 0.0, 612.0, 792.0)) // Default US Letter
80    }
81
82    /// Get the crop box (defaults to media box).
83    pub fn crop_box(&self) -> Rect {
84        self.get_rect(b"CropBox")
85            .unwrap_or_else(|| self.media_box())
86    }
87
88    /// Get the bleed box (defaults to crop box).
89    pub fn bleed_box(&self) -> Rect {
90        self.get_rect(b"BleedBox")
91            .unwrap_or_else(|| self.crop_box())
92    }
93
94    /// Get the trim box (defaults to crop box).
95    pub fn trim_box(&self) -> Rect {
96        self.get_rect(b"TrimBox").unwrap_or_else(|| self.crop_box())
97    }
98
99    /// Get the art box (defaults to crop box).
100    pub fn art_box(&self) -> Rect {
101        self.get_rect(b"ArtBox").unwrap_or_else(|| self.crop_box())
102    }
103
104    /// Get the page rotation.
105    pub fn rotation(&self) -> Rotation {
106        let degrees = self.dict.dict_get_i64(b"Rotate").unwrap_or(0);
107        Rotation::from_degrees(degrees)
108    }
109
110    /// Get the effective page width (accounting for rotation and crop box).
111    pub fn width(&self) -> f64 {
112        let crop = self.crop_box().normalized();
113        match self.rotation() {
114            Rotation::Rotate90 | Rotation::Rotate270 => crop.height().abs(),
115            _ => crop.width().abs(),
116        }
117    }
118
119    /// Get the effective page height (accounting for rotation and crop box).
120    pub fn height(&self) -> f64 {
121        let crop = self.crop_box().normalized();
122        match self.rotation() {
123            Rotation::Rotate90 | Rotation::Rotate270 => crop.width().abs(),
124            _ => crop.height().abs(),
125        }
126    }
127
128    /// Get the number of annotations on this page.
129    pub fn num_annots(&self) -> usize {
130        self.dict
131            .dict_get(b"Annots")
132            .and_then(|o| o.as_array())
133            .map(|a| a.len())
134            .unwrap_or(0)
135    }
136
137    /// Get the default transformation matrix for this page.
138    ///
139    /// Maps from default PDF coordinates (origin at bottom-left) to
140    /// the page's crop box, accounting for rotation.
141    pub fn default_matrix(&self) -> Matrix2D {
142        let crop = self.crop_box().normalized();
143        let rot = self.rotation();
144
145        let base = Matrix2D::translation(-crop.x1, -crop.y1);
146
147        match rot {
148            Rotation::None => base,
149            Rotation::Rotate90 => {
150                let rotate = Matrix2D::new(0.0, -1.0, 1.0, 0.0, 0.0, crop.width());
151                rotate * base
152            }
153            Rotation::Rotate180 => {
154                let rotate = Matrix2D::new(-1.0, 0.0, 0.0, -1.0, crop.width(), crop.height());
155                rotate * base
156            }
157            Rotation::Rotate270 => {
158                let rotate = Matrix2D::new(0.0, 1.0, -1.0, 0.0, crop.height(), 0.0);
159                rotate * base
160            }
161        }
162    }
163
164    /// Get the resource dictionary for this page.
165    pub fn resources(&self) -> Option<&PdfObject> {
166        self.dict.dict_get(b"Resources")
167    }
168
169    /// Get the content stream reference(s) for this page.
170    pub fn contents(&self) -> Option<&PdfObject> {
171        self.dict.dict_get(b"Contents")
172    }
173
174    /// Get the UserUnit value (default 1.0 = 1/72 inch).
175    pub fn user_unit(&self) -> f64 {
176        self.dict.dict_get_f64(b"UserUnit").unwrap_or(1.0)
177    }
178}