Skip to content
PDFDispatch

HTML to PDF in Rust

Rust has no PDF renderer that copes with today's HTML and CSS. The usual way out is to drive headless Chrome from a crate such as headless_chrome, which means a Chrome install next to your binary and a browser process to look after. Here the HTML goes to the PDFDispatch API over HTTP and the PDF comes back, so your container needs nothing but your binary.

What you need

  • Rust 1.85 or newer (edition 2024).
  • A PDFDispatch key from pdfdispatch.com/app/keys, in the environment variable PDD_KEY.
export PDD_KEY=pdd_your_key_here
[dependencies]
reqwest = { version = "0.13", features = ["multipart"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Without async

A command-line tool with no async runtime can use reqwest's blocking client. Add "blocking" to reqwest's features:

// src/main.rs
use std::time::Duration;

use reqwest::blocking::{
    Client,
    multipart::{Form, Part},
};

fn html_to_pdf(html: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let key = std::env::var("PDD_KEY")?;
    let page = Part::text(html.to_owned())
        .file_name("index.html")
        .mime_str("text/html")?;
    let form = Form::new()
        .part("files", page)
        .text("printBackground", "true");

    let res = Client::builder()
        .timeout(Duration::from_secs(90))
        .build()?
        .post("https://api.pdfdispatch.com/v1/convert/html")
        .bearer_auth(key)
        .multipart(form)
        .send()?;

    if !res.status().is_success() {
        return Err(format!("PDF failed: {} {}", res.status(), res.text()?).into());
    }

    Ok(res.bytes()?.to_vec())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pdf = html_to_pdf("<!doctype html><h1>Hello</h1>")?;
    std::fs::write("hello.pdf", pdf)?;
    Ok(())
}

The page must be named index.html. printBackground keeps background colours, which browsers leave out when they print. The 90-second timeout is longer than the API's own limit of 65 seconds, so a PDF that takes too long ends with the API's 504 and its error code.

With tokio

In a server, or for more than one PDF, use the async client. Make one reqwest::Client and share it: it keeps a pool of open connections, so later requests skip the TLS handshake. Pdfs below holds the client and the key. Both sit behind an Arc, so a clone is cheap and every clone shares the same pool.

// src/pdf.rs
use std::{fmt, sync::Arc, time::Duration};

use reqwest::multipart::{Form, Part};

const URL: &str = "https://api.pdfdispatch.com/v1/convert/html";

#[derive(Clone)]
pub struct Pdfs {
    client: reqwest::Client,
    key: Arc<str>,
}

#[derive(Debug)]
pub enum PdfError {
    /// Worth sending again after this pause: the rate limit, a full queue, a short outage.
    Retry { after: Duration, message: String },
    /// The same request fails the same way: fix the request, the key or the plan first.
    Failed(String),
    Network(reqwest::Error),
}

impl Pdfs {
    pub fn from_env() -> Pdfs {
        let key = std::env::var("PDD_KEY").expect("PDD_KEY is not set");
        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(90))
            .build()
            .expect("HTTP client");

        Pdfs { client, key: key.into() }
    }

    pub async fn html_to_pdf(
        &self,
        html: &str,
        options: &[(&str, &str)],
    ) -> Result<Vec<u8>, PdfError> {
        let page = Part::text(html.to_owned())
            .file_name("index.html")
            .mime_str("text/html")?;
        let mut form = Form::new()
            .part("files", page)
            .text("printBackground", "true");

        for (name, value) in options {
            form = form.text(name.to_string(), value.to_string());
        }

        let res = self
            .client
            .post(URL)
            .bearer_auth(&self.key)
            .multipart(form)
            .send()
            .await?;

        if res.status().is_success() {
            return Ok(res.bytes().await?.to_vec());
        }

        Err(PdfError::from_response(res).await)
    }
}

from_response, which turns an error answer into a PdfError, is in the next section. Use it:

// src/main.rs
mod pdf;

use pdf::Pdfs;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let html = r#"<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      @page { size: A4; margin: 15mm; }
      body { font-family: sans-serif; }
    </style>
  </head>
  <body><h1>Monthly report</h1></body>
</html>"#;

    let pdfs = Pdfs::from_env();
    let pdf = pdfs
        .html_to_pdf(html, &[("preferCssPageSize", "true")])
        .await?;
    tokio::fs::write("report.pdf", pdf).await?;

    Ok(())
}

preferCssPageSize makes the API use the size from your @page rule. Any other rendering option is one more pair, for example ("paperWidth", "210mm"). For a chart that draws after the page loads, send ("waitWindowStatus", "ready") and set window.status = 'ready' in the page once it is drawn.

Errors worth a retry

The API sends 429 for two different reasons. Code 42910 means more requests in this minute than your plan allows; the Retry-After header says how many seconds to wait. Codes 42902, 42903 and 42904 mean the account has no PDFs left for this period. A retry changes nothing until someone buys credits, changes the plan or its settings, or the next period starts. Decide by the code in the body:

Answer What to do
429, code 42910 Wait Retry-After seconds, then send it again.
429 with no code The engine's queue was full for a moment. Wait a few seconds.
500, 503 The engine failed or the browser crashed. Try again.
Anything else Fix the request, the key or the plan first. The same request fails the same way.

This goes at the end of src/pdf.rs:

impl PdfError {
    async fn from_response(res: reqwest::Response) -> PdfError {
        let status = res.status();
        let retry_after = res
            .headers()
            .get("retry-after")
            .and_then(|v| v.to_str().ok()?.parse().ok())
            .map(Duration::from_secs);
        let request_id = res
            .headers()
            .get("x-request-id")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("-")
            .to_owned();
        let body = res.text().await.unwrap_or_default();

        // Errors from the gateway are JSON with a numeric code; errors in the request are plain text.
        let code = serde_json::from_str::<serde_json::Value>(&body)
            .ok()
            .and_then(|json| json["code"].as_u64());
        let message = format!("{status} {body} (request {request_id})");

        let wait = match (status.as_u16(), code) {
            (429, Some(42910)) => retry_after.or(Some(Duration::from_secs(5))),
            (429, None) => Some(Duration::from_secs(5)),
            (500 | 503, _) => Some(Duration::from_secs(2)),
            _ => None,
        };

        match wait {
            Some(after) => PdfError::Retry { after, message },
            None => PdfError::Failed(message),
        }
    }
}

impl From<reqwest::Error> for PdfError {
    fn from(err: reqwest::Error) -> PdfError {
        PdfError::Network(err)
    }
}

impl fmt::Display for PdfError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PdfError::Retry { message, .. } | PdfError::Failed(message) => {
                write!(f, "PDF failed: {message}")
            }
            PdfError::Network(err) => write!(f, "PDF failed: {err}"),
        }
    }
}

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

And the retry loop: up to four tries, with a longer pause each time. Failed requests count no PDFs, so the retries cost nothing.

impl Pdfs {
    pub async fn html_to_pdf_with_retry(
        &self,
        html: &str,
        options: &[(&str, &str)],
    ) -> Result<Vec<u8>, PdfError> {
        let mut attempt = 1;

        loop {
            match self.html_to_pdf(html, options).await {
                Err(PdfError::Retry { after, .. }) if attempt < 4 => {
                    tokio::time::sleep(after * attempt).await;
                    attempt += 1;
                }
                result => return result,
            }
        }
    }
}

Many PDFs at once

A batch of statements or invoices goes faster in parallel. A Semaphore keeps four requests open at a time and a JoinSet collects the results as they finish:

// src/main.rs
mod pdf;

use std::sync::Arc;

use pdf::Pdfs;
use tokio::{sync::Semaphore, task::JoinSet};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pdfs = Pdfs::from_env();
    let at_once = Arc::new(Semaphore::new(4));
    let mut jobs = JoinSet::new();

    for customer in ["acme", "globex", "initech", "umbrella", "hooli", "stark"] {
        let pdfs = pdfs.clone();
        let at_once = at_once.clone();

        jobs.spawn(async move {
            let _permit = at_once.acquire_owned().await.expect("semaphore closed");
            let html = format!("<!doctype html><h1>Statement for {customer}</h1>");
            let pdf = pdfs.html_to_pdf_with_retry(&html, &[]).await;
            (customer, pdf)
        });
    }

    while let Some(job) = jobs.join_next().await {
        match job? {
            (customer, Ok(pdf)) => tokio::fs::write(format!("{customer}.pdf"), pdf).await?,
            (customer, Err(err)) => eprintln!("{customer}: {err}"),
        }
    }

    Ok(())
}

The semaphore caps the requests that are open at the same time. The plan caps the requests in each minute: 10 on Free and 60 to 300 on the paid plans (Limits). Four short PDFs at a time can pass 60 a minute, and then the API answers 42910 and its Retry-After slows the batch down. html_to_pdf_with_retry already waits for that. For large batches on a small plan, fewer permits waste fewer requests.

In an Axum route

// src/main.rs
mod pdf;

use axum::{
    Router,
    extract::{Path, State},
    http::{StatusCode, header},
    response::IntoResponse,
    routing::get,
};
use pdf::Pdfs;

async fn receipt(
    State(pdfs): State<Pdfs>,
    Path(id): Path<u64>,
) -> Result<impl IntoResponse, StatusCode> {
    let html = render_receipt(id);
    let pdf = pdfs
        .html_to_pdf_with_retry(&html, &[])
        .await
        .map_err(|err| {
            eprintln!("receipt {id}: {err}");
            StatusCode::BAD_GATEWAY
        })?;

    Ok((
        [
            (header::CONTENT_TYPE, "application/pdf".to_string()),
            (header::CONTENT_DISPOSITION, format!("inline; filename=\"receipt-{id}.pdf\"")),
        ],
        pdf,
    ))
}

fn render_receipt(id: u64) -> String {
    format!("<!doctype html><h1>Receipt {id}</h1>")
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/orders/{id}/receipt.pdf", get(receipt))
        .with_state(Pdfs::from_env());

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

render_receipt is whatever makes your HTML: format!, Askama, Tera or Maud. The API only sees the finished HTML.

Images and stylesheets

Anything with a public URL loads as it would in a browser. Files that are not public go in the same request. Add each one as another part named files and refer to it by its bare name:

let logo = Part::bytes(tokio::fs::read("assets/logo.png").await?).file_name("logo.png");
let css = Part::bytes(tokio::fs::read("assets/report.css").await?).file_name("report.css");

let form = Form::new()
    .part("files", page)
    .part("files", logo)
    .part("files", css)
    .text("printBackground", "true");

In the HTML, that is <img src="logo.png"> and <link rel="stylesheet" href="report.css">.

When it fails

Errors from the gateway, such as a wrong key or a used-up plan, are JSON with a numeric code. Errors in the request, such as a missing index.html, are a 400 with a short text. Both are in Limits and errors. Every answer has an X-Request-Id header. PdfError puts it in its message, so it lands in your logs; quote it in any e-mail to support.

Next

  • HTML to PDF: every field of this route.
  • Merge PDFs: join the PDFs of a batch into one file.
  • Webhooks: get the PDF delivered to your server instead of waiting for it.