Rust's PDF rendering story is thinner than Python's or Ruby's. pdfium-render provides high-fidelity rendering by binding PDFium (the same engine Chrome uses) but requires distributing a native PDFium binary. The poppler-rs crate wraps libpoppler but has limited rendering support. For most production Rust services, calling pdftoppm via Command or using the API is the most practical path.
Method 1: pdfium-render (PDFium bindings, highest fidelity)
pdfium-render binds Google's PDFium library. You need to download the PDFium binary separately — it doesn't ship with the crate.
# Cargo.toml
[dependencies]
pdfium-render = "0.8"
image = "0.25"# Download PDFium binary for your platform from:
# https://github.com/bblanchon/pdfium-binaries/releases
# Unzip to ./lib/ next to your binaryuse pdfium_render::prelude::*;
use std::path::Path;
fn pdf_to_jpg(
pdf_path: &str,
out_dir: &str,
dpi: u16,
) -> Result, PdfiumError> {
std::fs::create_dir_all(out_dir).ok();
// Initialize PDFium - points to the native .so/.dll
let pdfium = Pdfium::new(
Pdfium::bind_to_library(
Pdfium::pdfium_platform_library_name_at_path("./lib/")
).map_err(|e| PdfiumError::PdfiumLibraryInternalError(e))?
);
let doc = pdfium.load_pdf_from_file(pdf_path, None)?;
let render_config = PdfRenderConfig::new()
.set_target_width(0) // 0 = auto from DPI
.set_target_height(0)
.set_horizontal_dpi(dpi as i32)
.set_vertical_dpi(dpi as i32);
let mut paths = Vec::new();
for (i, page) in doc.pages().iter().enumerate() {
let bitmap = page.render_with_config(&render_config)?;
let image = bitmap.as_image();
let out_path = format!("{}/page-{:03}.jpg", out_dir, i + 1);
image
.save_with_format(&out_path, image::ImageFormat::Jpeg)
.map_err(|_| PdfiumError::ImageError)?;
paths.push(out_path);
}
Ok(paths)
}
fn main() -> Result<(), Box> {
let pages = pdf_to_jpg("document.pdf", "./pages", 150)?;
println!("Wrote {} pages", pages.len());
Ok(())
}
PDFium renders at the quality level Chrome uses internally — excellent fidelity. The main friction is distributing the PDFium native library alongside your binary.
Method 2: Command::new + pdftoppm (no Rust PDF crate)
Poppler's pdftoppm is available on most Linux servers. Calling it via Command::new avoids any Rust PDF dependency.
# Cargo.toml — no extra dependencies needed for this approachuse std::path::Path;
use std::process::Command;
use std::fs;
fn pdf_to_jpg(pdf_path: &str, out_dir: &str, dpi: u32) -> Result, Box> {
fs::create_dir_all(out_dir)?;
let prefix = format!("{}/page", out_dir);
let output = Command::new("pdftoppm")
.args([
"-jpeg",
"-r", &dpi.to_string(),
"-jpegopt", "quality=90",
pdf_path,
&prefix,
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("pdftoppm failed: {}", stderr).into());
}
// Collect output files
let mut pages: Vec = fs::read_dir(out_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name().to_string_lossy().ends_with(".jpg")
})
.map(|e| e.path().to_string_lossy().to_string())
.collect();
pages.sort();
Ok(pages)
}
fn main() -> Result<(), Box> {
let pages = pdf_to_jpg("document.pdf", "./pages", 150)?;
println!("Wrote {} pages", pages.len());
Ok(())
}
This approach has no compile-time dependencies — the binary just needs pdftoppm in PATH at runtime. Clean, simple, and the most practical choice for server deployments where poppler-utils is installed.
Method 3: ChangeThisFile API (reqwest, no native deps)
The API runs Poppler server-side. Source auto-detected from filename — pass target=jpg. Free tier: 1,000 conversions/month, no card needed.
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["multipart", "blocking"] }
tokio = { version = "1", features = ["full"] }
# For ZIP extraction (multi-page PDFs return a zip):
zip = "2"use reqwest::blocking::{Client, multipart};
use std::path::Path;
use std::fs;
const API_KEY: &str = "ctf_sk_your_key_here";
fn pdf_to_jpg_api(pdf_path: &str, out_dir: &str) -> Result, Box> {
fs::create_dir_all(out_dir)?;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let file_bytes = fs::read(pdf_path)?;
let filename = Path::new(pdf_path)
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let form = multipart::Form::new()
.part(
"file",
multipart::Part::bytes(file_bytes)
.file_name(filename)
.mime_str("application/pdf")?,
)
.text("target", "jpg");
let response = client
.post("https://changethisfile.com/v1/convert")
.header("Authorization", format!("Bearer {}", API_KEY))
.multipart(form)
.send()?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()).into());
}
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = response.bytes()?;
if content_type.contains("application/zip") {
// Multi-page: extract ZIP
let tmp_zip = format!("{}/pages.zip", out_dir);
fs::write(&tmp_zip, &body)?;
let zip_file = fs::File::open(&tmp_zip)?;
let mut archive = zip::ZipArchive::new(zip_file)?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let out_path = format!("{}/{}", out_dir, entry.name());
let mut out = fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out)?;
}
fs::remove_file(&tmp_zip)?;
} else {
fs::write(format!("{}/page-001.jpg", out_dir), &body)?;
}
let mut pages: Vec = fs::read_dir(out_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jpg"))
.map(|e| e.path().to_string_lossy().to_string())
.collect();
pages.sort();
Ok(pages)
}
fn main() -> Result<(), Box> {
let pages = pdf_to_jpg_api("document.pdf", "./pages")?;
println!("Got {} pages", pages.len());
Ok(())
}
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| pdfium-render | Embedded Rust apps, Chrome-level rendering fidelity | Must distribute PDFium native binary (~12MB); complex setup |
| Command + pdftoppm | Server deployments with poppler-utils installed | Runtime dependency on system poppler-utils |
| ChangeThisFile API | No C dependencies, WASM targets, minimal binaries | Network call; 25MB file limit on free tier |
Production tips
- Bundle PDFium with your binary. For pdfium-render deployments, include the PDFium .so/.dll in your Docker image or release archive. Set the library path at startup or use the auto-path detection.
- Check process exit status before reading output. pdftoppm exits non-zero on corrupt PDFs. Always check output.status.success() before globbing output files.
- Use blocking reqwest for simple CLI tools. The blocking feature avoids async complexity for one-shot scripts. For async Rust services, use the standard async reqwest API with .await.
- Parallelize with Rayon for batch PDF conversion. PDF rendering is CPU-bound — Rayon's par_iter() distributes pages across threads automatically: pages.par_iter().for_each(|page| render_page(page)).
- Handle the ZIP response for multi-page PDFs. The API returns a ZIP when the input is a multi-page PDF. Always check Content-Type: application/zip and extract accordingly.
For server deployments with poppler-utils installed, Command + pdftoppm is the cleanest approach. pdfium-render is worth the setup when you need Chrome-quality rendering in an embedded context. For zero-native-dep services, the API handles it via reqwest. Free tier: 1,000 conversions/month.