# pdf-writer, the low level PDF writer behind Typst

> typst/pdf-writer is a Rust library for producing PDF files one object at a time, with a main Pdf buffer and specialised writers that borrow from it, so a caller can build a document incrementally rather than assembling a tree of objects. It is the layer under Typst, it forbids unsafe code, and the examples cover images, colour profiles, video, forms and transparency.

**typst/pdf-writer** — A step-by-step PDF writer.

- Repository: https://github.com/typst/pdf-writer
- Stars: 745 · Forks: 51
- Language: Rust
- License: Apache-2.0
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/typst-pdf-writer

## The API shape and why it is one big buffer

pdf-writer is described as a step-by-step PDF writer, and the description of its architecture is unusual enough to be worth reading closely.

The entry point into the API is the main Pdf, which constructs the document into one big internal buffer. The top-level writer has many methods to create specialised writers for specific PDF objects, and they all follow the same pattern: they borrow the main buffer mutably, expose a builder pattern for writing individual fields in a strongly typed fashion, and finish up the object when dropped.

That last part is the design decision that makes the crate pleasant to use. A builder that finishes its object on drop means you write the fields of an object and then move on, with no explicit write call and no chance of forgetting one. The borrow is what makes it safe, since a specialised writer cannot outlive the buffer it is writing into, and the whole document ends up in one allocation rather than in a tree of objects that is assembled later.

The README notes there are a few more top-level structs with their own internal buffers, such as the builder for Content streams, but that wherever possible buffers are borrowed from parent writers to minimise allocations. The distinction matters for a document with many content streams, which is most documents, because copying each stream into the parent buffer would double the memory for no reason.

So the crate is a low level interface. It is the layer that knows about indirect references, cross-reference tables and trailers, and nothing about paragraphs, fonts or page layout. The name says it: a writer, not a document model. That is the right division for a library whose job is to emit a valid file, and it is why the crate is used by Typst rather than being Typst itself.

## The minimal example and what the PDF objects are

The README's minimal example creates a PDF with a single, empty A4 page, and it is worth walking through because every line corresponds to a PDF object.

```rust
use pdf_writer::{Pdf, Rect, Ref};

// Define some indirect reference ids we'll use.
let catalog_id = Ref::new(1);
let page_tree_id = Ref::new(2);
let page_id = Ref::new(3);

// Write a document catalog and a page tree with one A4 page that uses no resources.
let mut pdf = Pdf::new();
pdf.catalog(catalog_id).pages(page_tree_id);
pdf.pages(page_tree_id).kids([page_id]).count(1);
pdf.page(page_id)
    .parent(page_tree_id)
    .media_box(Rect::new(0.0, 0.0, 595.0, 842.0))
    .resources();

// Finish with cross-reference table and trailer and write to file.
std::fs::write("target/empty.pdf", pdf.finish())?;
```

The three Ref values are indirect object identifiers, and they are allocated by hand. That is the one piece of PDF that is unavoidably manual, because the format requires that objects refer to each other by number, and a streaming writer cannot invent numbers for objects it has not written yet. The comment in the example says so plainly: define some indirect reference ids we'll use.

The three calls map to the three structural objects of a PDF. The catalog is the root of the document, and it points at the page tree. The page tree lists the pages, with a count that has to agree with the number of kids. The page itself carries a parent pointer back to the tree, a media box, and a resources dictionary.

The media box is where A4 comes from. The numbers are 595 by 842 points, since a PDF unit is a 1/72 inch point and A4 is 210 by 297 millimetres. The page has no resources because it draws nothing.

The final line is the one that matters most. pdf.finish() writes the cross-reference table and the trailer, and without that the file is not a valid PDF even though every object is present. The comment in the example says finish with cross-reference table and trailer and write to file, and it is a genuine step: the cross-reference table is a map from object number to byte offset, and it can only be written once all the objects are in the buffer and their positions are known.

That is the shape of the whole library. You assemble objects in a buffer, and the last call turns the buffer into a file.

## The examples, the safety claim and the dependencies

The examples directory is the best guide to what the crate actually covers, and the file names describe the PDF features rather than the API.

There is hello.rs for the minimal case, image.rs for images, and image_cmyk.rs together with tiger-cmyk.jpg and separations.rs for the colour model cases. A CMYK image is a separate problem from an RGB one, because PDF colour is defined in a device-independent space and the conversion is where the complexity sits, and separation colour is the older mechanism for that.

icc.rs with the sRGB_v4.icc file is the embedded colour profile case, which is the correct way to get predictable colour across devices and is what a print workflow needs. bear-1280x720.jpg and bear-1280x720.mp4 with video.rs cover the two media types, and the MP4 example is interesting because a video in a PDF is a foreign object with a poster frame and a rectangular playback area, which the writer has to emit as a set of related objects.

forms.rs covers interactive forms, soft_masks.rs covers transparency, chunks.rs covers the chunked file structure that lets a PDF be streamed, and limits.rs is presumably about the limits of the format or the library.

The safety statement is short and worth quoting: this crate forbids unsafe code, but it depends on a few popular crates that use unsafe internally. The distinction is the standard Rust one, and for a library that writes a file format where a mistake produces a corrupt document, having the crate itself be entirely safe is a reasonable property. The dependencies behind it are small and unremarkable: bitflags, itoa, memchr and ryu, which is the float formatting crate, and the presence of ryu tells you the writer formats numbers directly into the buffer rather than going through string formatting.

The dev dependencies are where the examples get their data. The image crate with png and jpeg features, jpeg-decoder and jpeg-encoder, and miniz_oxide for the compression the benchmarks use, plus iai as the benchmark harness. The bench target is called oneshot with harness set to false, which is the convention for measuring a single long-running operation rather than a suite of small ones, which is the right shape for a document writer where you care about total throughput.

The crate is dual licensed under MIT and Apache 2.0, version 0.15.0, edition 2021, and the last push was on 2026-10-16.

## How this fits against the alternatives

The obvious alternative is a high level PDF library, and there are many: a wrapper around a native toolkit, or a document model with paragraphs and fonts and a layout engine.

Those give you a document. This gives you a file. The distinction matters when you need something the high level library does not model, such as a specific structure your consumer expects, or a size constraint, or a feature that arrived in the format before the library supported it.

The second alternative is generating the PDF text yourself. The format is documented, and a minimal file is not hard, which is why the minimal example here is twenty lines. But the cross-reference table, the object graph and the incremental update mechanism are the parts that are fiddly, and getting them right by hand for anything beyond a blank page is a poor use of time. This crate gives you the structure with the bookkeeping handled.

The comparison with Typst itself is the most useful one, because Typst is built on this crate. Typst is a typesetting system with a modern markup language, and it needs a PDF backend. Writing that backend from scratch would mean handling fonts, colour spaces, transparency groups, optional content and image compression, all of which the PDF format specifies in detail. So Typst depends on a crate that does the format, and the result is a very clean separation.

That separation has a practical consequence for anyone using pdf-writer directly. You are responsible for layout, because the crate has no concept of a page of text. You decide the font, the position, the colour and the drawing operators, and the crate makes sure the file is valid. For a server that renders a fixed report template, that is a reasonable division, and it is how people build PDF output that matches a house style exactly.

The other thing to weigh is the dependency on knowing PDF. The API is strongly typed and the builder pattern removes a lot of the error, but the object model is still the PDF object model, and you cannot use the crate without understanding the format. If your requirement is to typeset text, this is the wrong level and Typst is the right one.

## Conclusion

Adopt pdf-writer if you are generating PDF files in Rust and want full control over the file structure rather than a high level document model. Skip it if you want to typeset a document, because Typst or a LaTeX engine is the right tool for that and this is the layer beneath. Verify first by building the minimal example and opening the resulting file, then by checking the finish call, because the cross-reference table and trailer are only written when the document is finished.

## FAQ

### What is the pdf-writer crate?

It is a Rust library for writing PDF files step by step, with a main Pdf type that constructs the document into one internal buffer and specialised writers for individual PDF objects that borrow that buffer mutably, expose a typed builder and finish the object when dropped.

### How do I write a minimal PDF with pdf-writer?

Allocate three Ref identifiers for the catalog, the page tree and the page, then call pdf.catalog, pdf.pages with kids and count, and pdf.page with parent, media_box and resources, and finish with pdf.finish() which writes the cross-reference table and trailer before you write the bytes to a file.

### Which PDF features do the pdf-writer examples cover?

Images including CMYK, embedded ICC colour profiles, video, interactive forms, transparency with soft masks, chunked file structure and separations. The examples directory holds the data files the examples use, including sRGB_v4.icc and CMYK images.

### Does pdf-writer use unsafe code?

The crate itself forbids unsafe code, though the README notes that it depends on a few popular crates that use unsafe internally. Its direct dependencies are bitflags, itoa, memchr and ryu, with the dev dependencies covering image encoding and the benchmark harness.

### What licence is pdf-writer released under?

It is dual licensed under MIT and Apache 2.0, at version 0.15.0 with edition 2021. The crate is the low level PDF layer used by Typst, so it handles the file format rather than document layout.

## Sources

- [Issues](https://github.com/typst/pdf-writer/issues)
- [License: Apache-2.0](https://github.com/typst/pdf-writer/blob/main/LICENSE)
- [README](https://github.com/typst/pdf-writer/blob/main/README.md)
- [Releases](https://github.com/typst/pdf-writer/releases)
- [typst/pdf-writer on GitHub](https://github.com/typst/pdf-writer)

---

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