viewpoint_core/page/pdf/
mod.rs

1//! PDF generation functionality.
2//!
3//! This module provides the `PdfBuilder` for generating PDFs from pages.
4
5use std::path::Path;
6
7use tracing::{debug, info, instrument};
8use viewpoint_cdp::protocol::page::{PrintToPdfParams, PrintToPdfResult};
9
10use crate::error::PageError;
11
12use super::Page;
13use super::screenshot::base64_decode;
14
15/// Paper format for PDF generation.
16#[derive(Debug, Clone, Copy, PartialEq)]
17#[derive(Default)]
18pub enum PaperFormat {
19    /// Letter size (8.5 x 11 inches).
20    #[default]
21    Letter,
22    /// Legal size (8.5 x 14 inches).
23    Legal,
24    /// Tabloid size (11 x 17 inches).
25    Tabloid,
26    /// Ledger size (17 x 11 inches).
27    Ledger,
28    /// A0 size (33.1 x 46.8 inches).
29    A0,
30    /// A1 size (23.4 x 33.1 inches).
31    A1,
32    /// A2 size (16.5 x 23.4 inches).
33    A2,
34    /// A3 size (11.7 x 16.5 inches).
35    A3,
36    /// A4 size (8.27 x 11.69 inches).
37    A4,
38    /// A5 size (5.83 x 8.27 inches).
39    A5,
40    /// A6 size (4.13 x 5.83 inches).
41    A6,
42    /// Custom size in inches.
43    Custom { width: f64, height: f64 },
44}
45
46impl PaperFormat {
47    /// Get the width in inches.
48    pub fn width(&self) -> f64 {
49        match self {
50            PaperFormat::Letter => 8.5,
51            PaperFormat::Legal => 8.5,
52            PaperFormat::Tabloid => 11.0,
53            PaperFormat::Ledger => 17.0,
54            PaperFormat::A0 => 33.1,
55            PaperFormat::A1 => 23.4,
56            PaperFormat::A2 => 16.5,
57            PaperFormat::A3 => 11.7,
58            PaperFormat::A4 => 8.27,
59            PaperFormat::A5 => 5.83,
60            PaperFormat::A6 => 4.13,
61            PaperFormat::Custom { width, .. } => *width,
62        }
63    }
64
65    /// Get the height in inches.
66    pub fn height(&self) -> f64 {
67        match self {
68            PaperFormat::Letter => 11.0,
69            PaperFormat::Legal => 14.0,
70            PaperFormat::Tabloid => 17.0,
71            PaperFormat::Ledger => 11.0,
72            PaperFormat::A0 => 46.8,
73            PaperFormat::A1 => 33.1,
74            PaperFormat::A2 => 23.4,
75            PaperFormat::A3 => 16.5,
76            PaperFormat::A4 => 11.69,
77            PaperFormat::A5 => 8.27,
78            PaperFormat::A6 => 5.83,
79            PaperFormat::Custom { height, .. } => *height,
80        }
81    }
82}
83
84
85/// Margins for PDF generation in inches.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct Margins {
88    /// Top margin in inches.
89    pub top: f64,
90    /// Right margin in inches.
91    pub right: f64,
92    /// Bottom margin in inches.
93    pub bottom: f64,
94    /// Left margin in inches.
95    pub left: f64,
96}
97
98impl Margins {
99    /// Create uniform margins.
100    pub fn uniform(margin: f64) -> Self {
101        Self {
102            top: margin,
103            right: margin,
104            bottom: margin,
105            left: margin,
106        }
107    }
108
109    /// Create margins with vertical and horizontal values.
110    pub fn symmetric(vertical: f64, horizontal: f64) -> Self {
111        Self {
112            top: vertical,
113            right: horizontal,
114            bottom: vertical,
115            left: horizontal,
116        }
117    }
118
119    /// Create margins with all four values.
120    pub fn new(top: f64, right: f64, bottom: f64, left: f64) -> Self {
121        Self {
122            top,
123            right,
124            bottom,
125            left,
126        }
127    }
128}
129
130impl Default for Margins {
131    fn default() -> Self {
132        // Default margins match Chromium's defaults
133        Self {
134            top: 0.4,
135            right: 0.4,
136            bottom: 0.4,
137            left: 0.4,
138        }
139    }
140}
141
142/// Builder for generating PDFs.
143#[derive(Debug, Clone)]
144pub struct PdfBuilder<'a> {
145    page: &'a Page,
146    format: PaperFormat,
147    landscape: bool,
148    margins: Margins,
149    scale: f64,
150    print_background: bool,
151    header_template: Option<String>,
152    footer_template: Option<String>,
153    page_ranges: Option<String>,
154    prefer_css_page_size: bool,
155    path: Option<String>,
156}
157
158impl<'a> PdfBuilder<'a> {
159    /// Create a new PDF builder.
160    pub(crate) fn new(page: &'a Page) -> Self {
161        Self {
162            page,
163            format: PaperFormat::default(),
164            landscape: false,
165            margins: Margins::default(),
166            scale: 1.0,
167            print_background: false,
168            header_template: None,
169            footer_template: None,
170            page_ranges: None,
171            prefer_css_page_size: false,
172            path: None,
173        }
174    }
175
176    /// Set the paper format.
177    #[must_use]
178    pub fn format(mut self, format: PaperFormat) -> Self {
179        self.format = format;
180        self
181    }
182
183    /// Set landscape orientation.
184    #[must_use]
185    pub fn landscape(mut self, landscape: bool) -> Self {
186        self.landscape = landscape;
187        self
188    }
189
190    /// Set the margins.
191    #[must_use]
192    pub fn margins(mut self, margins: Margins) -> Self {
193        self.margins = margins;
194        self
195    }
196
197    /// Set all margins to the same value (in inches).
198    #[must_use]
199    pub fn margin(mut self, margin: f64) -> Self {
200        self.margins = Margins::uniform(margin);
201        self
202    }
203
204    /// Set each margin individually (in inches).
205    #[must_use]
206    pub fn margin_all(mut self, top: f64, right: f64, bottom: f64, left: f64) -> Self {
207        self.margins = Margins::new(top, right, bottom, left);
208        self
209    }
210
211    /// Set the scale factor (0.1 to 2.0).
212    #[must_use]
213    pub fn scale(mut self, scale: f64) -> Self {
214        self.scale = scale.clamp(0.1, 2.0);
215        self
216    }
217
218    /// Print background graphics.
219    #[must_use]
220    pub fn print_background(mut self, print_background: bool) -> Self {
221        self.print_background = print_background;
222        self
223    }
224
225    /// Set the header template HTML.
226    ///
227    /// The template can use special classes:
228    /// - `date`: current date
229    /// - `title`: document title
230    /// - `url`: document URL
231    /// - `pageNumber`: current page number
232    /// - `totalPages`: total pages
233    #[must_use]
234    pub fn header_template(mut self, template: impl Into<String>) -> Self {
235        self.header_template = Some(template.into());
236        self
237    }
238
239    /// Set the footer template HTML.
240    ///
241    /// Uses the same special classes as the header template.
242    #[must_use]
243    pub fn footer_template(mut self, template: impl Into<String>) -> Self {
244        self.footer_template = Some(template.into());
245        self
246    }
247
248    /// Set page ranges (e.g., "1-5, 8, 11-13").
249    #[must_use]
250    pub fn page_ranges(mut self, ranges: impl Into<String>) -> Self {
251        self.page_ranges = Some(ranges.into());
252        self
253    }
254
255    /// Prefer CSS `@page` size over the specified format.
256    #[must_use]
257    pub fn prefer_css_page_size(mut self, prefer: bool) -> Self {
258        self.prefer_css_page_size = prefer;
259        self
260    }
261
262    /// Save the PDF to a file.
263    #[must_use]
264    pub fn path(mut self, path: impl AsRef<Path>) -> Self {
265        self.path = Some(path.as_ref().to_string_lossy().to_string());
266        self
267    }
268
269    /// Generate the PDF.
270    ///
271    /// Returns the PDF as a byte buffer.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if:
276    /// - The page is closed
277    /// - The CDP command fails
278    /// - File saving fails (if a path was specified)
279    #[instrument(level = "info", skip(self), fields(format = ?self.format, landscape = self.landscape, has_path = self.path.is_some()))]
280    pub async fn generate(self) -> Result<Vec<u8>, PageError> {
281        if self.page.is_closed() {
282            return Err(PageError::Closed);
283        }
284
285        info!("Generating PDF");
286
287        let display_header_footer = self.header_template.is_some() || self.footer_template.is_some();
288
289        let params = PrintToPdfParams {
290            landscape: Some(self.landscape),
291            display_header_footer: Some(display_header_footer),
292            print_background: Some(self.print_background),
293            scale: Some(self.scale),
294            paper_width: Some(self.format.width()),
295            paper_height: Some(self.format.height()),
296            margin_top: Some(self.margins.top),
297            margin_bottom: Some(self.margins.bottom),
298            margin_left: Some(self.margins.left),
299            margin_right: Some(self.margins.right),
300            page_ranges: self.page_ranges.clone(),
301            header_template: self.header_template.clone(),
302            footer_template: self.footer_template.clone(),
303            prefer_css_page_size: Some(self.prefer_css_page_size),
304            transfer_mode: None,
305            generate_tagged_pdf: None,
306            generate_document_outline: None,
307        };
308
309        debug!("Sending Page.printToPDF command");
310        let result: PrintToPdfResult = self
311            .page
312            .connection()
313            .send_command("Page.printToPDF", Some(params), Some(self.page.session_id()))
314            .await?;
315
316        // Decode base64 data
317        let data = base64_decode(&result.data)?;
318        debug!(bytes = data.len(), "PDF generated");
319
320        // Save to file if path specified
321        if let Some(ref path) = self.path {
322            debug!(path = path, "Saving PDF to file");
323            tokio::fs::write(path, &data).await.map_err(|e| {
324                PageError::EvaluationFailed(format!("Failed to save PDF: {e}"))
325            })?;
326            info!(path = path, "PDF saved");
327        }
328
329        Ok(data)
330    }
331}