Skip to main content

mdpdf_render/
lib.rs

1use std::fs;
2use std::path::Path;
3
4use headless_chrome::{Browser, LaunchOptions};
5use headless_chrome::types::PrintToPdfOptions;
6
7/// PDF rendering configuration.
8pub struct PdfOptions {
9    /// Top margin in centimeters.
10    pub margin_top: f64,
11    /// Bottom margin in centimeters.
12    pub margin_bottom: f64,
13    /// Left margin in centimeters.
14    pub margin_left: f64,
15    /// Right margin in centimeters.
16    pub margin_right: f64,
17    /// Footer template HTML (supports `pageNumber`, `totalPages` classes).
18    pub footer_template: Option<String>,
19}
20
21impl Default for PdfOptions {
22    fn default() -> Self {
23        Self {
24            margin_top: 2.0,
25            margin_bottom: 2.5,
26            margin_left: 1.8,
27            margin_right: 1.8,
28            footer_template: None,
29        }
30    }
31}
32
33const CM_TO_INCHES: f64 = 0.3937;
34
35/// Render an HTML string to a PDF file.
36///
37/// Launches headless Chrome, loads the HTML, and prints to PDF.
38/// The HTML is written to a temporary file for Chrome to navigate to.
39pub fn html_to_pdf(html: &str, output: &Path, options: &PdfOptions) -> Result<u64, RenderError> {
40    // Write HTML to a temp file so Chrome can load it
41    let temp_dir = tempfile::tempdir()
42        .map_err(|e| RenderError::Io(format!("failed to create temp dir: {e}")))?;
43    let html_path = temp_dir.path().join("document.html");
44    fs::write(&html_path, html)
45        .map_err(|e| RenderError::Io(format!("failed to write temp HTML: {e}")))?;
46
47    let browser = Browser::new(LaunchOptions {
48        headless: true,
49        sandbox: false,
50        ..Default::default()
51    })
52    .map_err(|e| RenderError::Chrome(format!("failed to launch Chrome: {e}")))?;
53
54    let tab = browser
55        .new_tab()
56        .map_err(|e| RenderError::Chrome(format!("failed to create tab: {e}")))?;
57
58    let file_url = format!("file://{}", html_path.display());
59    tab.navigate_to(&file_url)
60        .map_err(|e| RenderError::Chrome(format!("failed to navigate: {e}")))?;
61
62    tab.wait_until_navigated()
63        .map_err(|e| RenderError::Chrome(format!("failed to wait for navigation: {e}")))?;
64
65    let footer = options.footer_template.clone().unwrap_or_else(|| {
66        r#"<div style="width:100%;text-align:center;font-size:9px;color:#888;font-family:sans-serif;">
67            <span class="pageNumber"></span> / <span class="totalPages"></span>
68        </div>"#.to_owned()
69    });
70
71    let pdf_data = tab
72        .print_to_pdf(Some(PrintToPdfOptions {
73            landscape: Some(false),
74            display_header_footer: Some(true),
75            print_background: Some(true),
76            scale: Some(1.0),
77            paper_width: Some(8.27),  // A4 width in inches
78            paper_height: Some(11.69), // A4 height in inches
79            margin_top: Some(options.margin_top * CM_TO_INCHES),
80            margin_bottom: Some(options.margin_bottom * CM_TO_INCHES),
81            margin_left: Some(options.margin_left * CM_TO_INCHES),
82            margin_right: Some(options.margin_right * CM_TO_INCHES),
83            header_template: Some("<span></span>".to_owned()),
84            footer_template: Some(footer),
85            page_ranges: None,
86            ignore_invalid_page_ranges: None,
87            prefer_css_page_size: None,
88            transfer_mode: None,
89            generate_tagged_pdf: None,
90            generate_document_outline: None,
91        }))
92        .map_err(|e| RenderError::Chrome(format!("failed to print PDF: {e}")))?;
93
94    // Ensure output directory exists
95    if let Some(parent) = output.parent() {
96        fs::create_dir_all(parent)
97            .map_err(|e| RenderError::Io(format!("failed to create output dir: {e}")))?;
98    }
99
100    fs::write(output, &pdf_data)
101        .map_err(|e| RenderError::Io(format!("failed to write PDF: {e}")))?;
102
103    Ok(pdf_data.len() as u64)
104}
105
106#[derive(Debug)]
107pub enum RenderError {
108    Io(String),
109    Chrome(String),
110}
111
112impl std::fmt::Display for RenderError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            RenderError::Io(msg) => write!(f, "IO error: {msg}"),
116            RenderError::Chrome(msg) => write!(f, "Chrome error: {msg}"),
117        }
118    }
119}
120
121impl std::error::Error for RenderError {}