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