Rust has no HTML renderer that handles modern CSS, so a Rust program that turns HTML into a PDF drives Chrome. We tested the two common crates, headless_chrome 1.0.22 and chromiumoxide 0.9.1 on Rust 1.93, plus a plain reqwest call to an API, on the same invoice. Every snippet below was compiled and run as shown, and we measured each default that came out wrong.
| Option | Pick it when |
|---|---|
| headless_chrome Blocking API, no async runtime | You want the simplest code and you're fine with a synchronous call (or spawn_blocking). |
| chromiumoxide Async, tokio or async-std | Your service is already async and you render many PDFs in one long-lived browser. |
| An API Chrome on someone else's server | Your binary should stay small (scratch or distroless images, Lambda) with no Chrome to install, patch and keep alive. |
Skip these: the wkhtmltopdf crate wraps wkhtmltopdf, whose project is archived and which has no flexbox or grid (wkhtmltopdf alternative). printpdf and genpdf build PDFs from drawing calls, not from HTML. Gotenberg is Chrome in a separate container (Gotenberg compared).
Save this as invoice.html in the project root (next to Cargo.toml). It asks for A4 in CSS and has a dark header bar:
<!doctype html>
<html><head><meta charset="utf-8"><style>
@page { size: A4; margin: 20mm 15mm; }
body { font-family: sans-serif; }
.top { display: flex; justify-content: space-between; align-items: center;
background: #1e3a8a; color: #fff; padding: 12px; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
</style></head><body>
<div class="top"><h1>Invoice #1042</h1><p>Due: 2026-11-01</p></div>
<table><tr><th>Item</th><th>Price</th></tr>
<tr><td>Consulting (3h)</td><td>€450.00</td></tr>
<tr><td>Hosting, Oct.</td><td>€19.00</td></tr></table>
</body></html>
cargo add headless_chrome anyhow. It finds Chrome or Chromium on the PATH, or uses the path in the CHROME environment variable. src/main.rs:
use headless_chrome::types::PrintToPdfOptions;
use headless_chrome::{Browser, LaunchOptions};
fn main() -> anyhow::Result<()> {
let path = std::fs::canonicalize("invoice.html")?;
let browser = Browser::new(
LaunchOptions::default_builder()
.sandbox(false) // needed as root and in most Docker images
.build()?,
)?;
let tab = browser.new_tab()?;
tab.navigate_to(&format!("file://{}", path.display()))?
.wait_until_navigated()?;
let pdf = tab.print_to_pdf(Some(PrintToPdfOptions {
prefer_css_page_size: Some(true), // use @page size/margins from the CSS
print_background: Some(true), // keep background colors
display_header_footer: Some(true),
header_template: Some("<span></span>".into()),
footer_template: Some(
r#"<div style="font-size:9px;width:100%;text-align:center">
Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>"#
.into(),
),
..Default::default()
}))?;
std::fs::write("invoice.pdf", pdf)?;
Ok(())
}
Result: a 1-page A4 PDF with the blue header bar and "Page 1 of 1" at the bottom, in 3.5 s including Chrome's start. To render a live page, pass its URL to navigate_to instead of the file:// path.
Build error on first compile: with Rust from apt (Debian, Ubuntu) rather than rustup, the build failed in the auto_generate_cdp build script with rustfmt not found: Os { code: 2, kind: NotFound }. The crate generates its DevTools bindings at build time and formats them with rustfmt. Fix: apt install rustfmt (rustup installs it by default), or the same line in your Dockerfile's build stage. A clean debug build then took about 2 minutes on 4 cores.
cargo add chromiumoxide futures anyhow and cargo add tokio --features full. src/main.rs:
use chromiumoxide::cdp::browser_protocol::page::PrintToPdfParams;
use chromiumoxide::{Browser, BrowserConfig};
use futures::StreamExt;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let html = std::fs::read_to_string("invoice.html")?;
let config = BrowserConfig::builder()
.no_sandbox() // needed as root and in most Docker images
.build()
.map_err(anyhow::Error::msg)?;
let (mut browser, mut handler) = Browser::launch(config).await?;
// The handler drives the DevTools connection. Stop when it ends,
// or the task never finishes (unwrap() on None panics instead).
let handle = tokio::spawn(async move {
while let Some(event) = handler.next().await {
if event.is_err() {
break;
}
}
});
let page = browser.new_page("about:blank").await?;
page.set_content(html).await?;
let params = PrintToPdfParams::builder()
.prefer_css_page_size(true) // use @page size/margins from the CSS
.print_background(true) // keep background colors
.display_header_footer(true)
.header_template("<span></span>")
.footer_template(
r#"<div style="font-size:9px;width:100%;text-align:center">
Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>"#,
)
.build();
let pdf = page.pdf(params).await?;
std::fs::write("invoice.pdf", pdf)?;
browser.close().await?;
browser.wait().await?;
handle.await?;
Ok(())
}
Result: the same 1-page A4 PDF with the header bar and footer, in 0.7 s including Chrome's start. set_content loads an HTML string; for a live page, use browser.new_page(url).
The example loop never exits. chromiumoxide's own examples drive the handler with loop { let _ = handler.next().await.unwrap(); } and end with handle.await. Copied as is, our program saved the PDF and then never returned: we killed it after 30 s. The handler task runs for as long as Chrome does, nothing in the example closes Chrome, so handle.await waits forever; and if Chrome does exit, next() returns None and the unwrap() panics. In a server, a pattern like this leaks a task and a Chrome process per request. Use while let Some(event) = handler.next().await, close the browser and wait for it, as above.
print_to_pdf(None) in headless_chrome and PrintToPdfParams::default() in chromiumoxide both send Chrome's print defaults. What we measured on the invoice above:
@page { size: A4 } unless you set prefer_css_page_size. Set it, or set paper_width/paper_height in inches (8.27 × 11.69 for A4).print_background: Some(true) / .print_background(true).font-size prints tiny glyphs. Always set font-size and width:100% in the template. If you set only a footer, Chrome prints its own default header (date and title), so pass <span></span> as the header, as above. The footer sits inside the bottom margin, so a margin of 0 hides it.font-family: Arial falls back silently. Install the fonts in the image or load them with @font-face (custom fonts in PDFs).With a downloaded Chrome (Chrome for Testing) on Ubuntu 23.10+, chromiumoxide's default config crashed at launch with Browser process exited with status ExitStatus(unix_wait_status(134)) and FATAL:zygote_host_impl_linux.cc:130] No usable sandbox! in the stderr it returns, because AppArmor blocks the unprivileged user namespaces Chrome's sandbox uses. Running as root (most Docker images) fails the same way, and headless_chrome's error for that case says so: "You need to set the sandbox(false) option when running as root". That's why the snippets call .no_sandbox() and .sandbox(false). Only render HTML you trust when the sandbox is off.
Shotmint renders in the same Chrome on its servers, with print backgrounds on and A4 by default, so your binary carries no browser. cargo add reqwest --features blocking,json and cargo add serde_json anyhow, then run it with SHOTMINT_KEY=... cargo run:
use std::time::Duration;
fn main() -> anyhow::Result<()> {
let html = std::fs::read_to_string("invoice.html")?;
let key = std::env::var("SHOTMINT_KEY")?;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(60))
.build()?;
let res = client
.post("https://shotmint.creasework.com/v1/pdf")
.header("X-API-Key", key)
.json(&serde_json::json!({
"html": html, // or "url": "https://example.com"
"prefer_css_page_size": true, // use @page size/margins from the CSS
"page_numbers": true // "1 / 3" footer
}))
.send()?;
let status = res.status();
let credits = res
.headers()
.get("x-credits-remaining")
.and_then(|v| v.to_str().ok())
.unwrap_or("?")
.to_string();
let bytes = res.bytes()?;
if !status.is_success() {
// errors come back as JSON with a message
anyhow::bail!("render failed: {status} {}", String::from_utf8_lossy(&bytes));
}
std::fs::write("invoice.pdf", &bytes)?;
println!("saved {} bytes, {credits} credits left", bytes.len());
Ok(())
}
Result: the same 1-page A4 invoice with the header bar and a "1/1" footer, in 0.32 s end to end; a wrong key exits with render failed: 401 Unauthorized and the JSON message. One PDF is one credit, however many pages it has: the 32-page Wikipedia article on Rust took 3.9 s with "url" instead of "html". In async code, drop the blocking feature and add .await after send() and bytes(). Other options: format (A3, A4, A5, Letter, Legal, Tabloid), landscape, margin (with a unit, e.g. "15mm"), header_template/footer_template, wait_until, screen_media; see the API reference.
pdftoppm -r 50 -png out.pdf p), not just by opening it in a viewer.Related: HTML to PDF in Go, page numbers and footers, CSS page breaks, background colors in PDFs, free HTML to PDF converter.
One PDF = one credit. Free keys get 25 renders a month, no signup. €5 buys 2,000 renders, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons