mdpdf-render 0.0.0-alpha.0

HTML to PDF rendering via headless Chrome
Documentation
use std::fs;
use std::path::Path;

use headless_chrome::{Browser, LaunchOptions};
use headless_chrome::types::PrintToPdfOptions;

/// PDF rendering configuration.
pub struct PdfOptions {
    /// Top margin in centimeters.
    pub margin_top: f64,
    /// Bottom margin in centimeters.
    pub margin_bottom: f64,
    /// Left margin in centimeters.
    pub margin_left: f64,
    /// Right margin in centimeters.
    pub margin_right: f64,
    /// Footer template HTML (supports `pageNumber`, `totalPages` classes).
    pub footer_template: Option<String>,
}

impl Default for PdfOptions {
    fn default() -> Self {
        Self {
            margin_top: 2.0,
            margin_bottom: 2.5,
            margin_left: 1.8,
            margin_right: 1.8,
            footer_template: None,
        }
    }
}

const CM_TO_INCHES: f64 = 0.3937;

/// Render an HTML string to a PDF file.
///
/// Launches headless Chrome, loads the HTML, and prints to PDF.
/// The HTML is written to a temporary file for Chrome to navigate to.
pub fn html_to_pdf(html: &str, output: &Path, options: &PdfOptions) -> Result<u64, RenderError> {
    // Write HTML to a temp file so Chrome can load it
    let temp_dir = tempfile::tempdir()
        .map_err(|e| RenderError::Io(format!("failed to create temp dir: {e}")))?;
    let html_path = temp_dir.path().join("document.html");
    fs::write(&html_path, html)
        .map_err(|e| RenderError::Io(format!("failed to write temp HTML: {e}")))?;

    let browser = Browser::new(LaunchOptions {
        headless: true,
        sandbox: false,
        ..Default::default()
    })
    .map_err(|e| RenderError::Chrome(format!("failed to launch Chrome: {e}")))?;

    let tab = browser
        .new_tab()
        .map_err(|e| RenderError::Chrome(format!("failed to create tab: {e}")))?;

    let file_url = format!("file://{}", html_path.display());
    tab.navigate_to(&file_url)
        .map_err(|e| RenderError::Chrome(format!("failed to navigate: {e}")))?;

    tab.wait_until_navigated()
        .map_err(|e| RenderError::Chrome(format!("failed to wait for navigation: {e}")))?;

    let footer = options.footer_template.clone().unwrap_or_else(|| {
        r#"<div style="width:100%;text-align:center;font-size:9px;color:#888;font-family:sans-serif;">
            <span class="pageNumber"></span> / <span class="totalPages"></span>
        </div>"#.to_owned()
    });

    let pdf_data = tab
        .print_to_pdf(Some(PrintToPdfOptions {
            landscape: Some(false),
            display_header_footer: Some(true),
            print_background: Some(true),
            scale: Some(1.0),
            paper_width: Some(8.27),  // A4 width in inches
            paper_height: Some(11.69), // A4 height in inches
            margin_top: Some(options.margin_top * CM_TO_INCHES),
            margin_bottom: Some(options.margin_bottom * CM_TO_INCHES),
            margin_left: Some(options.margin_left * CM_TO_INCHES),
            margin_right: Some(options.margin_right * CM_TO_INCHES),
            header_template: Some("<span></span>".to_owned()),
            footer_template: Some(footer),
            page_ranges: None,
            ignore_invalid_page_ranges: None,
            prefer_css_page_size: None,
            transfer_mode: None,
            generate_tagged_pdf: None,
            generate_document_outline: None,
        }))
        .map_err(|e| RenderError::Chrome(format!("failed to print PDF: {e}")))?;

    // Ensure output directory exists
    if let Some(parent) = output.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| RenderError::Io(format!("failed to create output dir: {e}")))?;
    }

    fs::write(output, &pdf_data)
        .map_err(|e| RenderError::Io(format!("failed to write PDF: {e}")))?;

    Ok(pdf_data.len() as u64)
}

#[derive(Debug)]
pub enum RenderError {
    Io(String),
    Chrome(String),
}

impl std::fmt::Display for RenderError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RenderError::Io(msg) => write!(f, "IO error: {msg}"),
            RenderError::Chrome(msg) => write!(f, "Chrome error: {msg}"),
        }
    }
}

impl std::error::Error for RenderError {}