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 binary
use 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 approach
use 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

ApproachBest forTradeoff
pdfium-renderEmbedded Rust apps, Chrome-level rendering fidelityMust distribute PDFium native binary (~12MB); complex setup
Command + pdftoppmServer deployments with poppler-utils installedRuntime dependency on system poppler-utils
ChangeThisFile APINo C dependencies, WASM targets, minimal binariesNetwork 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.