Rust has no mature pure-Rust HTML rendering engine capable of CSS-accurate PDF output. The practical options all involve either a headless browser or calling an external binary. chromiumoxide gives the cleanest async Rust API for headless Chrome. For simpler use cases, Command::new with wkhtmltopdf or Chrome's --print-to-pdf flag is more straightforward. The API is the right choice when you don't want a browser in your deployment.
Method 1: chromiumoxide (headless Chrome, async Rust)
chromiumoxide provides async Rust bindings to the Chrome DevTools Protocol (CDP). It controls headless Chrome to render HTML and export as PDF.
# Cargo.toml
[dependencies]
chromiumoxide = "0.7"
tokio = { version = "1", features = ["full"] }
futures = "0.3"# Chrome must be installed on the system
# Ubuntu/Debian:
apt install chromium-browser
# Or use the chrome-for-testing downloaduse chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::page::PrintToPdfParams;
use std::fs;
use futures::StreamExt;
#[tokio::main]
async fn main() -> Result<(), Box> {
html_to_pdf("report.html", "output.pdf").await?;
println!("Done");
Ok(())
}
async fn html_to_pdf(html_path: &str, out_path: &str) -> Result<(), Box> {
// Launch headless Chrome
let (mut browser, mut handler) = Browser::launch(
BrowserConfig::builder()
.no_sandbox() // needed when running as root (Docker)
.build()?,
)
.await?;
// Drive the handler in a background task
let handler_task = tokio::task::spawn(async move {
while let Some(event) = handler.next().await {
if event.is_err() { break; }
}
});
// Create a new tab
let page = browser.new_page("about:blank").await?;
// Load HTML content
let html_content = fs::read_to_string(html_path)?;
// For local HTML: use file:// URL or navigate + set content
page.set_content(&html_content).await?;
// Wait for page to be ready
page.wait_for_navigation().await?;
// Print to PDF
let pdf_params = PrintToPdfParams::builder()
.print_background(true)
.paper_width(8.27) // A4 width in inches
.paper_height(11.69) // A4 height in inches
.margin_top(0.59) // ~15mm
.margin_bottom(0.59)
.margin_left(0.59)
.margin_right(0.59)
.build()?;
let pdf_data = page.pdf(pdf_params).await?;
fs::write(out_path, pdf_data)?;
browser.close().await?;
handler_task.await?;
Ok(())
}
chromiumoxide gives full CSS rendering fidelity — flexbox, grid, CSS variables, print media queries — everything Chrome supports. The downside: Chrome needs to be installed (~500MB) and running headlessly requires a display or --no-sandbox in Docker.
Method 2: Command::new + wkhtmltopdf (simpler binary call)
For simple HTML documents that don't need full modern CSS, wkhtmltopdf via Command is much lighter than headless Chrome.
# Ubuntu/Debian
apt install wkhtmltopdfuse std::process::Command;
use std::io::Write;
use std::fs;
tempfile::NamedTempFile;
fn html_to_pdf(
html_content: &str,
out_path: &str,
) -> Result<(), Box> {
// Write HTML to a temp file
let mut tmp = tempfile::NamedTempFile::new()?;
tmp.write_all(html_content.as_bytes())?;
tmp.flush()?;
let tmp_path = tmp.path().to_string_lossy().to_string();
let output = Command::new("wkhtmltopdf")
.args([
"--page-size", "A4",
"--margin-top", "15mm",
"--margin-bottom", "15mm",
"--margin-left", "15mm",
"--margin-right", "15mm",
"--encoding", "UTF-8",
"--quiet",
"--disable-javascript", // security: don't run JS
&tmp_path,
out_path,
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("wkhtmltopdf failed: {}", stderr).into());
}
if !std::path::Path::new(out_path).exists() {
return Err("Output PDF not created".into());
}
Ok(())
}
fn main() -> Result<(), Box> {
let html = "Invoice
Amount: $99
";
html_to_pdf(html, "invoice.pdf")?;
println!("Done");
Ok(())
}
# Cargo.toml
[dependencies]
tempfile = "3"
Method 3: ChangeThisFile API (reqwest, no browser needed)
The API converts HTML to PDF server-side. Source auto-detected from filename — pass target=pdf. Free tier: 1,000 conversions/month, no card needed.
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["multipart", "blocking"] }
tempfile = "3"use reqwest::blocking::{Client, multipart};
use std::io::Write;
use std::fs;
const API_KEY: &str = "ctf_sk_your_key_here";
fn html_to_pdf_api(
html_content: &str,
out_path: &str,
) -> Result<(), Box> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
// Write HTML to temp file for multipart upload
let mut tmp = tempfile::NamedTempFile::with_suffix(".html")?;
tmp.write_all(html_content.as_bytes())?;
tmp.flush()?;
let html_bytes = fs::read(tmp.path())?;
let form = multipart::Form::new()
.part(
"file",
multipart::Part::bytes(html_bytes)
.file_name("document.html")
.mime_str("text/html")?,
)
.text("target", "pdf");
let resp = client
.post("https://changethisfile.com/v1/convert")
.header("Authorization", format!("Bearer {}", API_KEY))
.multipart(form)
.send()?;
if !resp.status().is_success() {
return Err(format!("API error: {}", resp.status()).into());
}
fs::write(out_path, resp.bytes()?)?;
Ok(())
}
fn main() -> Result<(), Box> {
let html = "Report
Content.
";
html_to_pdf_api(html, "report.pdf")?;
println!("Done");
Ok(())
}
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| chromiumoxide | Modern CSS, dashboards, complex layouts — full Chrome rendering | Chrome (~500MB) must be installed; async only; complex setup |
| Command + wkhtmltopdf | Simple HTML, invoices — lighter than Chrome | wkhtmltopdf binary required; limited CSS support |
| ChangeThisFile API | No browser install, Lambda/Fargate, minimal binaries | Network call; 25MB file limit on free tier |
Production tips
- chromiumoxide in Docker needs --no-sandbox. Add no_sandbox() to BrowserConfig when running as root (standard in Docker containers). Without it, Chrome refuses to start.
- Use set_content() not navigate() for HTML strings. chromiumoxide's navigate() requires a URL. For inline HTML, use page.set_content() followed by wait_for_navigation().
- Disable JavaScript in wkhtmltopdf for user HTML. Pass --disable-javascript to prevent JS execution in user-submitted templates. XSS in server-side PDF generation can have serious consequences.
- Cache the browser process for chromiumoxide in web services. Launching a new Chrome process per request takes 500ms-1s. For a web service, launch Chrome once at startup and reuse it across requests using Arc
>. - Use wkhtmltopdf --load-error-handling ignore for external resources. External CSS/image URLs can cause wkhtmltopdf to hang or fail. Add this flag to skip failed resource loads.
For Rust services requiring accurate CSS rendering, chromiumoxide is the right choice — it gives full Chrome fidelity with an idiomatic async API. For simpler HTML without modern CSS, wkhtmltopdf is much lighter. For Lambda or minimal deployments, the API handles it with just reqwest. Free tier: 1,000 conversions/month.