# printpdf: a Rust and WASM library for writing, reading and rendering PDF

> printpdf is a Rust crate that builds PDF documents from a list of drawing operations, and also reads them, with a WASM32 build so the same code runs in a browser. The HTML-to-PDF path is explicitly marked as still evolving, and the README is candid about a list of PDF features it does not support at all.

**fschutt/printpdf** — Rust / WASM library for reading, writing and rendering PDF

- Repository: https://github.com/fschutt/printpdf
- Website: https://fschutt.github.io/printpdf/
- Stars: 1,191 · Forks: 140
- Language: Rust
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/fschutt-printpdf

## The document model: pages, a list of operations, and a save step

printpdf is a Rust library for creating, reading, writing and rendering PDF documents, and its writing model is explicit in a way that makes it easy to reason about. A document is built, pages are attached to it, and each page is a vector of operations that the library serialises into the PDF content stream.

The basic example is short enough to read in full:

```rust
use printpdf::*;

fn main() {
    let mut doc = PdfDocument::new("My first PDF");
    let page1_contents = vec![Op::Marker { id: "debugging-marker".to_string() }];
    let page1 = PdfPage::new(Mm(210.0), Mm(297.0), page1_contents);
    let mut warnings = Vec::new();
    let pdf_bytes: Vec<u8> = doc
        .with_pages(vec![page1])
        .save(&PdfSaveOptions::default(), &mut warnings);
}
```

The units are physical. PdfPage::new takes Mm(210.0), Mm(297.0), which is A4 in millimetres, and the page content is a vec of Op values. The document is created with a title, pages are attached with with_pages, and save takes options and a mutable warnings vector, returning the bytes.

That warnings vector is a design choice worth noticing. The library does not panic on the things that can go wrong while building a document; it collects them and hands them back, so a caller can decide whether a particular warning matters. The font example in the README makes the same point in a comment: text showing ops must sit between StartTextSection and EndTextSection, most viewers drop text outside of one, and save() warns if you forget.

The Op enum is the whole drawing vocabulary, and it is close to the PDF operator set rather than to a high level API. You set colours, you set line thickness, you set the dash pattern, you set the line join and cap style, you draw a line or a polygon, you push and pop the graphics state, and you reference an XObject for an image. There is no scene graph in the core operation set, which is what makes the output predictable.

The crate type is declared as both cdylib and rlib, so the same source compiles to a native library and to a C dynamic library suitable for the WASM32 demo that is hosted at fschutt.github.io/printpdf.

## Graphics, colour models and the graphics state stack

The graphics example in the README is where the low level nature of the API becomes concrete, and it is worth reading in full because it shows the whole sequence of state changes a shape needs.

A shape is a Line with a list of points, where each LinePoint is a Point plus a bezier flag. The comment explains the flag: bezier true would make the point a bezier control handle for a curve. And a Polygon holds one or more PolygonRing, with a comment that matters for anyone filling a path with a hole: if you want holes, wind one ring counterclockwise.

Colour is explicit and there are two models in play. The fill colour is Color::Cmyk(Cmyk::new(0.0, 0.23, 0.0, 0.0, None)), which is a print-oriented model, and the outline colour is Color::Rgb(Rgb::new(0.75, 1.0, 0.64, None)), which is a screen-oriented one. Both take an optional trailing value, and the graphics example uses a dash pattern of LineDashPattern::new(0.0, &[20.0, 10.0]) with the comment dash, gap.

The advanced graphics state is built with a builder chain:

```rust
let extgstate = ExtendedGraphicsState::default()
    .with_overprint_stroke(true)
    .with_blend_mode(BlendMode::multiply());
```

That is where overprint and blending live, and it matches the features list entry for advanced graphics, overprint, blending and similar. Note how it is used. The state is attached to the document through doc.add_graphics_state(extgstate), and then applied on the page with Op::LoadGraphicsState, wrapped in Op::SaveGraphicsState and Op::RestoreGraphicsState.

That pairing is the pattern to understand. The graphics state is a stack, and the content stream pushes and pops it around the drawing operations that need it. A square is drawn directly, then the triangle is drawn inside a saved and restored state that carries the extended graphics state, the dash pattern, the round join and cap, the fill colour and a thicker outline.

The winding order is part of the polygon rather than a separate setting, WindingOrder::NonZero in the example, and the paint mode is PaintMode::FillStroke, so fill and stroke are combined in one op rather than being two separate draws.

## Images and fonts, and where the experimental label bites

The images example shows the resource path, and it is a different shape from the drawing path. An image is decoded into a RawImage, registered on the document, and then referenced by identifier on the page:

```rust
let image_bytes = include_bytes!("assets/img/BMP_test.bmp");
let mut warnings = Vec::new();
// requires --feature bmp
let image = RawImage::decode_from_bytes(image_bytes, &mut warnings).unwrap();

// In the PDF, an image is an `XObject`, identified by a unique `XObjectId`
let image_xobject_id = doc.add_image(&image);

let page1_contents = vec![
    Op::UseXobject { 
        id: image_xobject_id.clone(), 
        transform: XObjectTransform::default() 
    }
];
```

Three practical notes come with it. Images only get compressed in release mode, so you might get huge PDFs of 6 MB or more in debug mode. To make the process faster, use BufReader instead of reading directly from the file. And scaling of images is implicitly done to fit one pixel equals one dot at 300 dpi, which is a fixed assumption rather than a configurable DPI.

Fonts follow the same register then reference pattern, with the font parsed first and added to the document:

```rust
let roboto_bytes = include_bytes!("assets/fonts/RobotoMedium.ttf");
let font_index = 0; // face index inside a .ttc collection; 0 for a plain .ttf/.otf
let mut font_warnings = Vec::new();
let font = ParsedFont::from_bytes(roboto_bytes, font_index, &mut font_warnings).unwrap();

// printpdf automatically keeps track of which fonts are used in the PDF
let font_id = doc.add_font(&font);
```

The comment about printpdf keeping track of which fonts are used connects to the minifying feature in the list, auto-subsetting fonts. If the library knows which glyphs a document actually uses, it can subset the embedded font and drop the rest.

The font_index parameter is the collection detail people miss. A.ttc file is a collection of faces and 0 selects the first, while a plain.ttf or.otf passes 0 as well.

Now the honest part, which is the feature matrix. Reading is marked as experimental for embedded fonts and Unicode support, and for image decoding. Writing is supported, with the image feature for writing. So the library is strongest in the direction of producing PDFs, and reading support is explicitly a work in progress.

## What the README says is not supported, and what is only experimental

Two lists in the README are worth more than any feature list, because they tell you where the library will refuse to help.

The first is the not supported list. Gradients, patterns, file attachements, Open Prepress Interface, halftoning images, conformance and error checking for various PDF standards, and embedded Javascript.

That list has consequences that are easy to underestimate. No conformance checking means printpdf will happily produce a file that a strict validator rejects, and you will not be told. No gradients or patterns means the advanced graphics support is overprint, blending and similar rather than the whole shading model. No embedded Javascript and no file attachments means you cannot produce a PDF with an attachment or a script, which some workflows require. No Open Prepress Interface means the output is a general document, not a print-ready file with bleed and preflight data.

The second list is the experimental one. Rendering PDF pages to SVG, one standalone SVG file per page, which you can then use to render the PDF page into an image using resvg. Extracting text from PDF pages, with automatic decoding of Unicode, line breaks and text positions. And XHTML layouting for simple page layout, which uses the azul-layout and xmlparser crates and is described as good enough for basic page layouting, book rendering, reports and forms, with automatic page-breaking.

So the reading side of the library has three separate capabilities at different maturity levels, and the text extraction and SVG rendering are the ones flagged as experimental. The XHTML layouting is the one with the most ambition, since automatic page breaking is the hard part of any layout engine.

And then the note at the top of the README, which is the most useful sentence in the document for deciding anything. HTML-to-PDF rendering through PdfDocument::from_html and the html feature works for basic layouts, tables, page breaks, headers and footers, but is still evolving. For pixel-exact control, position PDF elements manually with ops.

That is the project stating the trade-off in its own words. The HTML path is convenient and will change under you. The ops path is stable and requires you to place every element. Which one you want depends entirely on who decides where things go on the page.

## A crate that grew past its release tag

The version picture here is unusual, and anyone pinning a dependency needs to look carefully.

The repository has a single GitHub release, v0.5.0, dated 2021-12-18. But the Cargo.toml in the repository declares version 0.12.8, with authors Felix Schütt and Julien Schminke, edition 2021, and rust-version 1.88. The website is fschutt.github.io/printpdf, the crate is on crates.io as printpdf, and the documentation is on docs.rs.

So the GitHub release tags are not the version history. The real version history is the crate on crates.io, and 0.12.8 is well past 0.5.0. The repository is not archived and the last push was on 2026-10-19, so work is landing in the tree even though no new GitHub release object was created.

The rust-version floor of 1.88 is explained in a comment in the manifest, and it is a good example of a dependency constraint propagating upward. lopdf 0.44 is edition-2024 code with rust-version 1.88, so that is printpdf's true floor too, and declaring it lets MSRV-aware resolvers see it instead of failing deep inside the dependency tree on older toolchains.

The manifest also shows careful attention to what ships. The exclude list drops the top level web demo files, the index.html, script.js, style.css, subset.ttf, the build and generation scripts, and the four documentation files API.md, COMPARISON.md, STRUCTS.md and SYNTAX.md. The comment above it is unusually explicit about why the rest stays in. Only the top level web demo and dev files are excluded, because the old globs matched nothing at all, and everything under examples/assets and tests/ ships on purpose despite being around 9 MB of the package, because the library tests and the examples use include_bytes! and include_str! on those files, so excluding them breaks cargo test of the published crate, which distro packagers run. The package sits at about 87 percent of the crates.io 10 MiB cap, and the comment says the publish safety check in ci.yaml watches the size and that large assets should not be added.

That is a maintainer who knows exactly what the packaging cost is and has documented the trade-off in the manifest for the next person.

The repository listing shows the surrounding structure. There is a src/ directory, an examples/ directory with a long list of programs, a tests/ directory, a defaultfonts/ directory, a subset.ttf file, and a set of documents at the root: API.md, COMPARISON.md, PPDF.md, STRUCTS.md, SYNTAX.md, plus CHANGELOG.md and CHANGELOG2.md. COMPARISON.md is the one to read first if you are evaluating this against another PDF library, and the examples list is a good index of what is actually implemented, with names like debug_displaylist, debug_pagination, debug_text_node_inheritance, baseline_alignment, otf-font-subset, css_shapes, margin_collapse and flex_cards indicating how much of the layout and debug surface has been built out.

## Conclusion

printpdf fits a Rust developer who needs to generate PDFs programmatically with full control over the drawing operations, or who wants the same code to run in the browser through the WASM32 build, and who is comfortable positioning elements by hand. It is the wrong tool if you need a WYSIWYG HTML to PDF pipeline, since the README marks that path as still evolving and limited to basic layouts, and the wrong tool if your documents need gradients, patterns, file attachments, halftoning, embedded JavaScript or standards conformance checking. Before adopting, read the COMPARISON.md file at the repository root against the library you are replacing, and check the crate version, which is 0.12.8 in Cargo.toml, against the single GitHub release tag, which is v0.5.0 from 2021.

## FAQ

### How do I create a PDF with printpdf?

Create a PdfDocument with a title, build each page as a PdfPage with a vector of Op operations and a physical size such as Mm(210.0), Mm(297.0) for A4, attach the pages with with_pages, and call save with PdfSaveOptions::default() and a warnings vector, which returns the PDF bytes.

### Can printpdf convert HTML to PDF?

There is an html feature with PdfDocument::from_html, and the README states it works for basic layouts such as tables, page breaks, headers and footers but is still evolving. For pixel-exact control, the README advises positioning PDF elements manually with ops instead.

### What PDF features does printpdf not support?

Gradients, patterns, file attachements, Open Prepress Interface, halftoning images, conformance and error checking for various PDF standards, and embedded Javascript. Rendering pages to SVG, extracting text from pages and XHTML layouting are all listed as experimental.

### What version of printpdf should I depend on?

The Cargo.toml in the repository declares version 0.12.8 with rust-version 1.88, and the only GitHub release tag is v0.5.0 from 2021. The crate on crates.io carries the current version, so use the crates.io version rather than the repository tag, and note that the MSRV floor of 1.88 comes from the lopdf dependency.

## Sources

- [fschutt/printpdf on GitHub](https://github.com/fschutt/printpdf)
- [License: MIT](https://github.com/fschutt/printpdf/blob/master/LICENSE)
- [Project website](https://fschutt.github.io/printpdf/)
- [README](https://github.com/fschutt/printpdf/blob/master/README.md)
- [Releases](https://github.com/fschutt/printpdf/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/fschutt-printpdf
