Converting PDF files to images might seem like a routine task, but when you peel back the layers, the process reveals hidden complexities—especially in a Rust-based pipeline. Earlier this week, I integrated PDF support into Convertify, a free Rust-powered image converter, and what started as a simple addition quickly turned into a deep dive into libvips, rendering backends, and performance trade-offs. The experience underscored how even small implementation choices can ripple into unexpected challenges.
A deceptively simple starting point
My initial approach relied on libvips’ reputation for effortless image handling. After all, loading a standard PNG or JPG file in Rust with VipsImage::new_from_file is almost trivial. I assumed PDFs would follow the same pattern. The first attempt used a straightforward path with a DPI parameter:
let image = VipsImage::new_from_file(&format!("{}[dpi={}]", file_path, dpi))?;This worked—for single-page PDFs. libvips accepts PDF paths with a DPI suffix and returns a rendered image without ceremony. For most users, that might be sufficient. But as with many things in software, the real world introduces variables.
The multi-page PDF challenge
Single-page documents are one thing, but multi-page PDFs introduce a new set of requirements. Each page must be rendered individually, and the output needs to be either segmented into separate files or consolidated into a single archive. Convertify handles this by probing the PDF for its page count before rendering:
let probe = VipsImage::new_from_file(&file_path)?;
let quantity_pages = probe.get_n_pages();With the page count in hand, the system iterates through each index, rendering them sequentially using a zero-based page parameter:
let image = VipsImage::new_from_file(&format!(
"{}[dpi={},page={}]",
file_path, dpi, num_page
))?;After rendering, individual files are temporarily stored and then zipped into a downloadable archive. The cleanup process removes temporary files and registry entries to maintain server hygiene. However, as I soon discovered, the implementation wasn’t as tidy as it appeared.
Rendering backends and subtle discrepancies
Here’s where things get interesting. libvips doesn’t render PDFs natively. Instead, it delegates the work to external libraries like poppler or pdfium, depending on how it was compiled. On most Linux distributions, poppler is the default via libvips-dev. This setup worked flawlessly on a fresh Ubuntu 24 EC2 instance—until I compared the rendered output against Adobe Acrobat.
The differences were subtle but noticeable, particularly in text positioning and edge rendering. Poppler uses Splash, a rasterizer that produces acceptable results for most use cases. Pdfium, on the other hand, leverages Skia’s analytical coverage rasterizer, delivering crisper edges and more accurate text placement on complex layouts. For applications where visual fidelity matters, the choice of backend can be critical.
To audit your libvips installation and verify the rendering engine, run:
vips --version
vips -l | grep pdfIf pdfiumload appears in the output alongside pdfload, you have the option to force pdfium for higher-quality rendering. For Convertify, poppler proved sufficient for the majority of uploaded files, but the distinction is worth noting for future iterations.
DPI: balancing quality and performance
Another lesson emerged around DPI handling. Users often request high-resolution outputs, but the practical implications can be severe. A 600 DPI A4 page generates a raw RGBA buffer that approaches 139 MB in memory. While libvips streams data in tiles to avoid out-of-memory errors, the performance impact is still significant, and the resulting file size becomes unwieldy.
To mitigate this, Convertify now enforces a DPI clamp between 72 and 300. The logic is straightforward:
let dpi = match params.get("dpi") {
Some(value) => match value.parse::<u32>() {
Ok(num) => num.clamp(72, 300),
Err(_) => 150,
},
None => 150,
};This approach ensures consistent performance without sacrificing print-quality output. A 300 DPI A4 page produces a 2480×3508 pixel image, resulting in roughly 900 KB JPG files—ideal for most use cases.
A cleanup bug hiding in plain sight
Not all challenges were technical; some were procedural. During a routine log review, I noticed the cleanup routine reporting hundreds of deleted files, even though ./tmp was empty. The issue stemmed from a logical oversight: the system counted files slated for deletion before actually removing them from disk. For multi-page PDFs, page files were deleted immediately after zipping, but their registry entries lingered. The cleaner then flagged these stale entries as successfully removed, inflating the count.
The fix involved refining the deletion logic to count only files actually removed from disk:
match tokio::fs::remove_file(&path).await {
Ok(_) => {
old_files_deleted += 1;
reg.remove(&file_name);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
reg.remove(&file_name); // Skip counting stale entries
}
Err(e) => {
eprintln!("Failed to delete {file_name}: {e}");
}
}This ensures accurate reporting and prevents false positives in system health metrics.
What’s next for Convertify
PDF conversion is now live, with features like DPI selection (72, 150, or 300) and multi-page ZIP downloads. Users can test the functionality at convertifyapp.net/pdf-to-png and convertifyapp.net/pdf-to-jpg. The next step involves evaluating whether to expose pdfium explicitly via a build option for enhanced rendering quality or to standardize on poppler and document its behavior for users who prioritize compatibility.
For now, the Rust-powered converter handles the majority of real-world PDFs efficiently. The journey from a naive assumption to a robust implementation highlighted how small details—rendering backends, DPI constraints, and cleanup logic—can collectively shape the end-user experience.
AI summary
Learn how Rust and libvips simplify PDF to image conversion, revealing rendering backends, DPI trade-offs, and cleanup pitfalls in a real-world project.