How to Generate PDFs in Rust with Skia: A Complete Guide Using the skia-safe Crate
Ilya Nixan
Generating PDFs programmatically is one of those tasks that sounds simple until you actually try it. Most libraries either give you a low-level stream of bytes to wrestle with, or a high-level template engine that falls apart the moment you need precise control over layout, typography, or graphics. There is a middle ground — and it is the same graphics engine that renders every web page you print to PDF in Chrome, every screen on Android, and every frame in a Flutter app: Google Skia.
This guide shows how to use Skia from Rust through the skia-safe crate to produce vector PDFs with full control over text, shapes, images, and multi-page layouts.
Key takeaways
- Skia is Google's open-source 2D graphics library, in active development since 2005, with over 2 million downloads of its Rust bindings on crates.io.
- The skia-safe crate (v0.93) provides safe, idiomatic Rust bindings with a typestate pattern that catches malformed PDF construction at compile time.
- Skia produces true vector PDFs with selectable text, not rasterized images — the same approach Chrome uses for its print-to-PDF feature.
- It supports PDF/A archival conformance, tagged/accessible PDFs (PDF/UA), font subsetting via HarfBuzz, and multi-page documents with mixed page sizes.
- Prebuilt binaries ship by default, so the first build takes under one minute instead of 20-40 minutes from source.
What is Skia and why use it for PDF generation in Rust
Skia is an open-source 2D graphics library maintained by Google. It has been in active development since 2005 and is one of the most battle-tested rendering engines in existence. When you print a web page to PDF in Google Chrome, Skia's PDF backend is what translates the rendered page into a PDF file. When Android draws its UI, Skia handles the rendering. Flutter uses Skia as its rendering engine for cross-platform apps.
What makes Skia compelling for programmatic PDF generation in Rust:
- True vector output. Text remains selectable, shapes stay sharp at any zoom level, and file sizes stay reasonable — because Skia translates its drawing operations into native PDF primitives rather than rasterizing pixels.
- Full 2D graphics API. Bezier paths, gradients, affine transforms, clip regions, blend modes, and typographic controls — everything you would expect from a professional graphics library, and it all maps to PDF.
- Tagged and accessible PDFs. Skia supports PDF structure element trees and node IDs, which means you can produce documents that meet PDF/UA accessibility standards.
- PDF/A archival conformance. A single metadata flag enables PDF/A-2b output for long-term document preservation — required for legal, medical, and government documents.
- Font subsetting. When you embed fonts, Skia strips out unused glyphs via HarfBuzz, keeping file sizes compact.
Setting up the skia-safe crate for Rust PDF generation
The skia-safe crate provides safe, idiomatic Rust bindings for Skia. It wraps the C++ library through FFI while exposing an API that feels natural in Rust — complete with ownership semantics, lifetime guarantees, and a typestate pattern that catches entire categories of bugs at compile time.
Add it to your project:
[dependencies]
skia-safe = "0.93"
The pdf feature is enabled by default, so no additional flags are needed. By default, the crate downloads prebuilt Skia binaries for your platform, which means the first build takes under a minute rather than the 20-40 minutes it would take to compile Skia from source.
How to generate a PDF file in Rust with Skia
The core concept is straightforward: create a document, begin a page, draw on the canvas, end the page, close the document. Here is a minimal example that produces a single-page PDF:
use skia_safe::pdf;
fn main() {
let mut output = Vec::new();
// Create a document — US Letter size (612 x 792 points)
let mut document = pdf::new_document(&mut output, None)
.begin_page((612, 792), None);
let canvas = document.canvas();
// Draw a filled rectangle
let mut paint = skia_safe::Paint::default();
paint.set_color(skia_safe::Color::from_rgb(41, 98, 255));
canvas.draw_rect(
skia_safe::Rect::from_xywh(72.0, 72.0, 468.0, 100.0),
&paint,
);
// Draw text
let font = skia_safe::Font::default();
paint.set_color(skia_safe::Color::WHITE);
canvas.draw_str("Hello from Skia + Rust", (90.0, 130.0), &font, &paint);
// Finalize
document.end_page().close();
std::fs::write("hello.pdf", &output).unwrap();
}
Run this, open hello.pdf, and you will see a blue rectangle with white text — all as vector graphics, fully selectable and zoomable.

Compile-time safety with the typestate pattern
One of the most elegant aspects of the Rust bindings is the typestate pattern on the Document type. The document transitions between two states: Open (ready to begin a page) and OnPage (actively drawing).
// Document<state::Open> — can begin a page or close the document
let document = pdf::new_document(&mut output, None);
// Document<state::OnPage> — can access the canvas or end the page
let page = document.begin_page((612, 792), None);
let canvas = page.canvas();
// Back to Document<state::Open> — can begin another page or close
let document = page.end_page();
document.close();
If you try to call canvas() on a document that has no open page, or try to close() a document while a page is active, the code will not compile. This is not a runtime check or an assertion — it is a compile-time guarantee enforced by Rust's type system. You cannot produce a malformed PDF because the compiler prevents it.
Compare this with C++ or Python PDF libraries where these mistakes result in corrupted output, silent failures, or runtime crashes. In Rust with skia-safe, the compiler is your proofreader.
Adding PDF metadata for SEO and accessibility
PDF metadata is what search engines, document management systems, and accessibility tools use to understand your document. Skia exposes this through the Metadata struct:
use skia_safe::pdf::{self, Metadata};
let metadata = Metadata {
title: "Q4 Financial Report".to_string(),
author: "Finance Team".to_string(),
subject: "Quarterly results and projections".to_string(),
creator: "Report Generator v2.1".to_string(),
..Default::default()
};
let mut output = Vec::new();
let document = pdf::new_document(&mut output, Some(&metadata));
For archival compliance, enable PDF/A with a single flag:
let metadata = Metadata {
pdf_a: true,
..Default::default()
};
This produces PDF/A-2b conformant output — suitable for legal documents, medical records, and any content that needs to be readable decades from now.
Creating multi-page PDF documents in Rust
Real documents are rarely a single page. Skia handles multi-page PDFs naturally — each call to begin_page starts a new page, and pages can have different sizes:
let mut output = Vec::new();
let document = pdf::new_document(&mut output, None);
// Page 1 — A4 portrait
let mut page = document.begin_page((595, 842), None);
draw_cover_page(page.canvas());
let document = page.end_page();
// Page 2 — A4 landscape
let mut page = document.begin_page((842, 595), None);
draw_data_table(page.canvas());
let document = page.end_page();
// Page 3 — back to portrait
let mut page = document.begin_page((595, 842), None);
draw_charts(page.canvas());
let document = page.end_page();
document.close();
Mixed orientations and page sizes within a single document — something many PDF libraries struggle with — works out of the box.
Drawing shapes, text, and paths in Skia PDFs
Skia's Canvas is a full-featured 2D drawing surface. Everything you draw on it is converted to vector PDF primitives.
Shapes and paths
let canvas = page.canvas();
let mut paint = Paint::default();
paint.set_anti_alias(true);
// Filled rectangle
paint.set_color(Color::from_rgb(59, 130, 246));
paint.set_style(skia_safe::PaintStyle::Fill);
canvas.draw_rect(Rect::from_xywh(50.0, 50.0, 200.0, 100.0), &paint);
// Stroked circle
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width(2.0);
canvas.draw_circle((300.0, 100.0), 50.0, &paint);
// Custom bezier path
let mut path = skia_safe::Path::new();
path.move_to((400.0, 50.0));
path.cubic_to((450.0, 20.0), (500.0, 80.0), (550.0, 50.0));
path.line_to((550.0, 150.0));
path.close();
paint.set_style(skia_safe::PaintStyle::Fill);
canvas.draw_path(&path, &paint);
Text rendering with selectable output
let typeface = skia_safe::Typeface::from_name("Helvetica", skia_safe::FontStyle::bold())
.unwrap_or_else(skia_safe::Typeface::default);
let font = skia_safe::Font::from_typeface(&typeface, 24.0);
let mut paint = Paint::default();
paint.set_color(Color::BLACK);
canvas.draw_str("Section Title", (72.0, 72.0), &font, &paint);
// Smaller body text
let body_font = skia_safe::Font::from_typeface(&typeface, 12.0);
canvas.draw_str("Body text goes here.", (72.0, 100.0), &body_font, &paint);
The text in the output PDF is selectable and searchable — not rasterized images of characters.
Affine transforms
canvas.save();
canvas.translate((200.0, 300.0));
canvas.rotate(45.0, None);
canvas.draw_rect(Rect::from_xywh(-50.0, -25.0, 100.0, 50.0), &paint);
canvas.restore();
Transforms are composable and translate directly to PDF's transformation matrix — no rasterization involved.
Embedding images in Rust-generated PDFs
Embedding images in PDFs is where many libraries add unnecessary file size. Skia handles this intelligently:
let image_data = std::fs::read("photo.jpg").unwrap();
let data = skia_safe::Data::new_copy(&image_data);
let image = skia_safe::Image::from_encoded(data).expect("Failed to decode image");
canvas.draw_image(&image, (72.0, 200.0), None);
When JPEG encoding support is configured in the metadata, Skia passes JPEG data through directly into the PDF without re-encoding — preserving quality and keeping file sizes minimal. Without it, images are deflate-compressed, which can increase file size significantly for photographic content.
You can control this through the metadata:
let metadata = Metadata {
encoding_quality: 85, // JPEG quality (101 = lossless/deflate)
..Default::default()
};
Rust PDF library comparison: skia-safe vs printpdf vs krilla
The Rust ecosystem offers several approaches to PDF generation. Here is how they compare:
| Library | Type | Vector output | Font subsetting | PDF/A | Tagged PDF | Layout engine | Maturity |
|---|---|---|---|---|---|---|---|
| skia-safe | Skia bindings (C++ FFI) | Yes | Yes (HarfBuzz) | Yes | Yes | No (canvas API) | 20+ years (Skia) |
| printpdf | Pure Rust | Yes | Limited | No | No | No | Stable |
| genpdf | Pure Rust (on printpdf) | Yes | Limited | No | No | Basic | Unmaintained |
| krilla | Pure Rust (on pdf-writer) | Yes | Yes (OpenType) | Yes | Yes | No | Newer |
| Headless Chrome | Browser runtime | Yes | Yes | No | No | Full HTML/CSS | Mature |
skia-safe gives you a full 2D graphics API backed by over 20 years of development at Google. Vector output, font subsetting, tagged PDFs, PDF/A support. The tradeoff is a C++ dependency — though prebuilt binaries make this painless in practice.
printpdf is pure Rust with no C dependencies. It works well for simple documents but requires manual positioning of every element. No layout engine, limited typography support.
genpdf builds a higher-level layout API on top of printpdf. It is pure Rust and easier to use for simple documents, but the project has been unmaintained for several years.
krilla is a newer pure Rust library with a modern API, excellent OpenType support, and features like gradients and blend modes. It is a strong choice if avoiding C dependencies is a priority, though it is less battle-tested than Skia.
Headless Chrome / Puppeteer gives you full HTML/CSS rendering but requires shipping a browser runtime. Heavyweight, but useful when you need to render complex web layouts.
The choice depends on your constraints. If you need precise control over graphics and typography, want proven reliability, and are comfortable with a C++ dependency, Skia is the most capable option. If you need a pure Rust solution, krilla or printpdf are worth evaluating.
Known limitations of Skia's PDF backend
Skia's PDF backend is comprehensive, but it has boundaries worth knowing about:
Blend mode support. Most PDF-standard blend modes work (Multiply, Screen, Overlay, etc.), but some Skia-specific modes like SrcATop, DstATop, Xor, and Plus have no PDF equivalent. If your drawing uses these, the output may differ from what you see on screen.
Rasterization fallback. Features without a native PDF representation — such as perspective transforms on text — are rasterized at the DPI specified in metadata (raster_dpi, default 72). For print-quality output, increase this to 300 or higher, with the tradeoff of larger file sizes.
Build from source. If you need to customize Skia features beyond what prebuilt binaries offer, building from source takes 20-40 minutes. The binary-cache feature (enabled by default) avoids this for standard configurations.
Production architecture for Rust PDF generation
For production systems that generate many documents — invoices, reports, certificates — a clean architecture separates content from rendering:
struct DocumentContent {
title: String,
sections: Vec<Section>,
footer: String,
}
struct Section {
heading: String,
body: String,
charts: Vec<ChartData>,
}
fn render_document(content: &DocumentContent) -> Vec<u8> {
let mut output = Vec::new();
let metadata = Metadata {
title: content.title.clone(),
creator: "My Document Engine".to_string(),
..Default::default()
};
let mut doc = pdf::new_document(&mut output, Some(&metadata));
for section in &content.sections {
let mut page = doc.begin_page((595, 842), None); // A4
render_section(page.canvas(), section);
doc = page.end_page();
}
doc.close();
output
}
This separation means your business logic never touches the PDF API directly. Content flows in, bytes flow out. You can test the rendering independently, swap out the PDF backend without changing business logic, and parallelize document generation across threads — since each Document owns its output buffer.
Getting started with Skia PDF generation in Rust
Add skia-safe to your Cargo.toml, write a few lines of drawing code, and open the resulting PDF. The learning curve is gentle if you have used any 2D graphics API before — Canvas, Cairo, Core Graphics, or even HTML5 Canvas. The concepts transfer directly.
The skia-safe crate is at version 0.93 with over 2 million downloads on crates.io. The prebuilt binary cache means your first build is fast, and the typestate pattern means your first PDF is correct.
For applications that need reliable, high-quality PDF output — whether that is invoices, reports, tickets, certificates, or any document where layout precision matters — Skia through Rust gives you the same rendering engine trusted by billions of devices, with the safety guarantees that Rust is known for.
We used this exact approach to build dxpdf — our open-source DOCX-to-PDF converter written in Rust and powered by Skia. It converts Word documents to high-fidelity PDFs in ~115ms without requiring Microsoft Office or LibreOffice.
Frequently asked questions
What is the best Rust library for generating PDFs?
It depends on your requirements. skia-safe is the most feature-complete option, offering vector output, font subsetting, tagged PDFs, and PDF/A support through Google's Skia engine. For pure Rust without C dependencies, krilla and printpdf are strong alternatives. For rendering HTML/CSS to PDF, headless Chrome remains the most reliable approach.
Does Skia produce vector or rasterized PDFs?
Skia produces true vector PDFs. Text is selectable and searchable, shapes remain sharp at any zoom level, and drawing operations are translated into native PDF primitives. Rasterization only occurs as a fallback for features that have no PDF-native representation, such as perspective transforms.
Can I create accessible and PDF/A-compliant documents with skia-safe?
Yes. Skia supports PDF structure element trees and node IDs for creating tagged, accessible PDFs that meet PDF/UA standards. For archival compliance, setting pdf_a: true in the Metadata struct produces PDF/A-2b conformant output.
How long does skia-safe take to build?
With the default binary-cache feature enabled, the first build takes under one minute because prebuilt Skia binaries are downloaded for your platform. Without the cache (building Skia from C++ source), expect 20-40 minutes.
Is skia-safe the same engine that Chrome uses for print-to-PDF?
Yes. When you use Chrome's "Save as PDF" or "Print to PDF" feature, the browser renders the page through Skia's PDF backend — the same engine that skia-safe exposes to Rust. This is also the rendering engine used by Android for its UI and by Flutter for cross-platform apps.
How does skia-safe handle fonts in PDFs?
Skia embeds fonts directly into the PDF. With HarfBuzz integration (enabled via the textlayout feature), it performs font subsetting — stripping out unused glyphs to keep file sizes compact. TrueType fonts use glyph-ID encoding for accurate rendering.