Rust has no single crate that turns HTML into a PDF, so the right choice depends on whether you need HTML and CSS fidelity or just programmatic drawing. For pixel-accurate HTML layouts, drive a headless Chromium with chromiumoxide or headless_chrome and call print_to_pdf. For text reports with no browser, use genpdf. For low-level vector work, use printpdf. To ship zero native dependencies, call a hosted API like PDF4.dev over reqwest. This guide shows real code for each.
Which Rust PDF approach should you use?
The decision comes down to four axes: do you need HTML and CSS, do you want async, how big can the binary or image be, and how much native tooling you can install. The table maps each crate against those axes so you can pick before writing code.
| Approach | HTML/CSS fidelity | Async required | Binary / image size | Native deps |
|---|---|---|---|---|
| printpdf | None (draw by coordinate) | No | Small (under 30 MB) | None |
| genpdf | None (document builder) | No | Small (under 30 MB) | None |
| chromiumoxide | Full (real Chromium) | Yes (tokio/async-std) | Large (400-700 MB image) | Chromium binary |
| headless_chrome | Full (real Chromium) | No (blocking API) | Large (400-700 MB image) | Chromium binary |
| WeasyPrint subprocess | High (no JavaScript) | No | Medium | Python + WeasyPrint |
| PDF4.dev (hosted) | Full (server-side Chromium) | Optional | Tiny (just reqwest) | None |
The split is clean: if your document is laid out in HTML and CSS, you need a real browser engine somewhere, either bundled in your image or hosted by someone else. If your document is a structured report or label you build field by field, a pure-Rust crate keeps your container small and removes the Chromium maintenance burden.
There is no mature pure-Rust crate that renders arbitrary HTML and CSS to PDF. Every "Rust HTML to PDF" path either drives Chromium, shells out to WeasyPrint, or calls a hosted API. Treat any claim of pure-Rust HTML rendering with caution.
How do you generate a PDF from HTML in Rust with chromiumoxide?
Use chromiumoxide when you need full HTML and CSS fidelity and you already run an async (tokio) stack. chromiumoxide drives a headless Chromium over the Chrome DevTools Protocol, navigates to a page or sets content, and calls print_to_pdf, which maps to the CDP Page.printToPDF command. The output matches what Chromium prints, including web fonts, flexbox, and grid.
Add the crate and a runtime to Cargo.toml:
[dependencies]
chromiumoxide = { version = "0.7", features = ["tokio-runtime"] }
tokio = { version = "1", features = ["full"] }
futures = "0.3"Then launch the browser, load HTML, and print:
use chromiumoxide::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::page::PrintToPdfParams;
use futures::StreamExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Launch a headless Chromium and keep its event loop running.
let (browser, mut handler) =
Browser::launch(BrowserConfig::builder().build()?).await?;
let handle = tokio::spawn(async move {
while let Some(event) = handler.next().await {
if event.is_err() {
break;
}
}
});
let html = "<h1>Invoice INV-001</h1><p>Total: 1,500.00 EUR</p>";
let page = browser.new_page("about:blank").await?;
// set_content waits for the DOM, then we wait for fonts/images.
page.set_content(html).await?;
page.wait_for_navigation().await?;
let pdf = page
.pdf(PrintToPdfParams {
print_background: Some(true),
prefer_css_page_size: Some(true),
..Default::default()
})
.await?;
std::fs::write("invoice.pdf", pdf)?;
browser.close().await?;
handle.await?;
Ok(())
}The PrintToPdfParams struct exposes the same knobs as the CDP command: landscape, margin_top, paper_width, scale, display_header_footer, and header_template. Set print_background: true so CSS backgrounds render, since Chromium drops them by default in print mode.
chromiumoxide needs a running event-loop task (the handler) for the whole session. If you drop that task, every page call hangs. This is the most common source of "my chromiumoxide code froze" bug reports.
When should you use headless_chrome instead?
Use headless_chrome when you want HTML to PDF without committing to an async runtime. headless_chrome is a synchronous, blocking Rust binding to the DevTools Protocol, so you call methods in a straight line without .await or a tokio reactor. The trade-off is less control over concurrency: each tab call blocks the thread until it returns.
use headless_chrome::{Browser, types::PrintToPdfOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let browser = Browser::default()?;
let tab = browser.new_tab()?;
// Navigate to a real URL, or use a data: URL for inline HTML.
tab.navigate_to("https://example.com")?;
tab.wait_until_navigated()?;
let options = PrintToPdfOptions {
landscape: Some(false),
print_background: Some(true),
prefer_css_page_size: Some(true),
..Default::default()
};
let pdf_bytes = tab.print_to_pdf(Some(options))?;
std::fs::write("page.pdf", pdf_bytes)?;
Ok(())
}For inline HTML rather than a live URL, encode the markup as a data:text/html;base64, URL and pass it to navigate_to. headless_chrome can download a known-good Chromium build on first run via its fetcher, which helps in CI but adds startup latency to the first render. Pin a system Chromium with the CHROME environment variable in production to skip that download.
How do you build a PDF in Rust without a browser using genpdf?
Use genpdf when your document is text-heavy (reports, letters, invoices laid out as flowing content) and you want to avoid bundling Chromium. genpdf is a document-builder crate on top of printpdf: you add paragraphs, set fonts, and it handles line wrapping and page breaks for you. The whole pipeline is pure Rust, so the binary stays small and has no native runtime dependency beyond a font file.
use genpdf::{elements, fonts, style, Document, SimplePageDecorator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// genpdf needs an embedded font; point at a TTF directory.
let font_family =
fonts::from_files("./fonts", "LiberationSans", None)?;
let mut doc = Document::new(font_family);
doc.set_title("Monthly report");
let mut decorator = SimplePageDecorator::new();
decorator.set_margins(20);
doc.set_page_decorator(decorator);
doc.push(
elements::Paragraph::new("Monthly report")
.styled(style::Style::new().bold().with_font_size(20)),
);
doc.push(elements::Break::new(1));
doc.push(elements::Paragraph::new(
"Revenue grew 12 percent month over month.",
));
doc.render_to_file("report.pdf")?;
Ok(())
}genpdf bundles font glyphs into the file, so non-Latin scripts and emoji need a font that includes those glyphs. It does not parse HTML or CSS, so you express layout through its element tree (Paragraph, TableLayout, Image, Break). For a structured invoice or shipping label, that is fine. For a design that a marketer maintains in HTML, it is the wrong tool.
When is printpdf the right choice?
Use printpdf when you need exact control over coordinates, vector graphics, or embedding existing assets, and you are willing to compute layout yourself. printpdf is the low-level crate that genpdf builds on. You create a document, add pages and layers, then place text, lines, shapes, and images at precise points measured in millimeters. There is no automatic text wrapping or pagination.
use printpdf::*;
use std::fs::File;
use std::io::BufWriter;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A4 is 210 x 297 mm. Create one page with one layer.
let (doc, page1, layer1) =
PdfDocument::new("Certificate", Mm(210.0), Mm(297.0), "Layer 1");
let current_layer = doc.get_page(page1).get_layer(layer1);
let font = doc.add_builtin_font(BuiltinFont::HelveticaBold)?;
// Place text at an explicit x/y from the bottom-left origin.
current_layer.use_text(
"Certificate of completion",
24.0,
Mm(35.0),
Mm(240.0),
&font,
);
// Draw a horizontal rule.
let line = Line {
points: vec![
(Point::new(Mm(35.0), Mm(232.0)), false),
(Point::new(Mm(175.0), Mm(232.0)), false),
],
is_closed: false,
};
current_layer.add_line(line);
doc.save(&mut BufWriter::new(File::create("certificate.pdf")?))?;
Ok(())
}printpdf gives you the most control and the smallest dependency footprint, but you own every coordinate. The origin is the bottom-left corner and the Y axis grows upward, which trips up developers used to top-left screen coordinates. Reach for printpdf when you generate tickets, labels, or certificates with a fixed template, or when you need precise vector output that a browser would not reproduce exactly.
Can you call WeasyPrint from Rust?
Yes, by running the WeasyPrint command-line tool as a subprocess with std::process::Command. WeasyPrint is a Python library that renders HTML and CSS to PDF without a browser, with strong support for paged-media CSS (@page, page counters, running headers). It does not execute JavaScript, so it suits server-rendered documents rather than client-side apps.
use std::process::Command;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Pipe HTML on stdin (-) and write PDF to a file.
let html = "<h1>Statement</h1><p>Balance: 240.00 EUR</p>";
let output = Command::new("weasyprint")
.arg("-") // read HTML from stdin
.arg("statement.pdf")
.arg("--encoding")
.arg("utf-8")
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child
.stdin
.take()
.unwrap()
.write_all(html.as_bytes())?;
child.wait_with_output()
})?;
if !output.status.success() {
eprintln!("weasyprint failed: {:?}", output.status);
}
Ok(())
}The cost is operational: your container now needs Python plus WeasyPrint plus their system libraries (Pango, cairo, GDK-PixBuf). That is lighter than a full Chromium but heavier than a pure-Rust crate. WeasyPrint is a good middle ground when you need real HTML and CSS layout, no JavaScript, and you would rather install Python than a browser. See the official WeasyPrint documentation for the supported CSS subset.
How do you generate PDFs in Rust with no infrastructure using PDF4.dev?
Call PDF4.dev over reqwest when you want full HTML and CSS fidelity but refuse to bundle, patch, and scale a Chromium binary yourself. PDF4.dev is a hosted API: you POST HTML or a template id with data, and it renders the PDF server-side with headless Chromium. Your Rust binary stays tiny because the only dependency is an HTTP client. There is no browser to install, no cold start to fight, and no system libraries to track.
The minimal call is one POST to https://pdf4.dev/api/v1/render with a Bearer key:
curl -X POST https://pdf4.dev/api/v1/render \
-H "Authorization: Bearer p4_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice {{number}}</h1><p>Total: {{total}}</p>",
"data": { "number": "INV-001", "total": "1,500.00 EUR" },
"delivery": "url"
}'The delivery field controls the response shape: "url" returns a signed link that expires after 24 hours (best for large PDFs and async jobs), "base64" returns the bytes inline as JSON, and omitting it returns the raw application/pdf body. The data object fills {{variables}} via Handlebars, so you can store a template once and render it with different values per request. This is the no-infrastructure option in this list: you trade a network hop for zero browser maintenance.
Want to test HTML to PDF before writing any Rust? Try the free HTML to PDFTry it free tool to see how your markup renders, then move the same HTML into the API call. For live pages, the Webpage to PDFTry it free tool captures a URL the same way the API does.
What is the catch with bundling Chromium in a Rust container?
Bundling a headless Chromium in a Rust Docker image inflates it from under 30 MB to roughly 400 to 700 MB and pulls in dozens of shared libraries that you must keep patched. A static Rust binary is famously small, but Chromium needs fonts, libnss3, libatk, libgbm, and a long tail of GTK and X11 libraries. Your slim image stops being slim, and every Chromium security update becomes your update.
Three concrete consequences to plan for:
- Image size and cold start. A 500 MB image pulls slower on every deploy and every autoscale event. On serverless platforms the cold-start penalty of unpacking Chromium can add seconds to the first request.
- Missing fonts and emoji. A bare base image renders Chinese, Arabic, and emoji as tofu boxes because the fonts are absent. You must install
fonts-notoandfonts-noto-color-emojiexplicitly, which adds more megabytes. - Zombie processes and memory. A crashed render can leave a Chromium process holding memory. Long-running services need a process reaper (
--initordumb-init) and a cap on concurrent tabs, or the container leaks until the OOM killer fires.
If those costs are acceptable and you want everything in-process, chromiumoxide or headless_chrome are correct. If you would rather not own a browser fleet, a pure-Rust crate (for non-HTML documents) or a hosted API (for HTML documents) removes the problem entirely.
Which option should you choose?
Pick by what your document is and what your deployment tolerates. There is no universally best crate, only the best fit for HTML fidelity, async, and image size.
- HTML and CSS document, async stack, browser is fine: use chromiumoxide. Full Chromium fidelity, fits a tokio service.
- HTML and CSS document, no async, browser is fine: use headless_chrome. Blocking API, same Chromium output.
- HTML and CSS document, want a tiny binary: call PDF4.dev over
reqwest. No browser in your image, Handlebars templates, signed URL delivery. - Text report or letter, no HTML: use genpdf. Pure Rust, automatic pagination, small binary.
- Tickets, labels, certificates, precise vector layout: use printpdf. Full coordinate control, smallest footprint.
- HTML and CSS, no JavaScript, Python is acceptable: shell out to WeasyPrint. Strong paged-media CSS, lighter than Chromium.
For most web applications that already render HTML, the real decision is "host Chromium yourself" (chromiumoxide or headless_chrome) versus "let someone host it" (PDF4.dev). If you generate fewer than a few thousand PDFs a day and do not want to maintain a browser image, the hosted path is faster to ship. If you generate at high volume and have the DevOps capacity, the in-process crates remove the network hop. For non-HTML documents, skip the browser question entirely and reach for genpdf or printpdf.
If you are weighing the same decision in another language, the trade-offs carry over almost unchanged. See generate a PDF from HTML in Go and PDF generation in Node.js for the equivalent ecosystems, or the complete HTML to PDF guide for the cross-language overview.
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.



